@webwriter/interactive-video
Version:
(WIP) Enhance learning by adding interactive content in popups to videos for an engaging, interactive experience.
702 lines (605 loc) • 22.9 kB
text/typescript
import { html, _$LE } from "lit";
import { LitElementWw } from "@webwriter/lit";
import { property, query, queryAssignedElements } from "lit/decorators.js";
import "@shoelace-style/shoelace/dist/themes/light.css";
import { SlButton, SlRange, SlIcon } from "@shoelace-style/shoelace";
import styles from "./webwriter-interactive-video.styles";
//Tabler
import playerPlay from "@tabler/icons/filled/player-play.svg";
import playerPause from "@tabler/icons/filled/player-pause.svg";
import { provide } from "@lit/context";
import {
InteractiveVideoContext,
videoContext,
} from "../../utils/interactive-video-context";
import { InteractiveVideoOptions } from "../../components/options-panel/interactive-video-options";
import { VideoInputOverlay } from "../../components/video-input-overlay/video-input-overlay";
import { VideoControlsBar } from "../../components/video-controls-bar/video-controls-bar";
import { VideoChapterDrawer } from "../../components/video-chapter-drawer/video-chapter-drawer";
import { InteractionsProgressBar } from "../../components/interactions-progress-bar/interactions-progress-bar";
import { WwVideoInteraction } from "../webwriter-video-interaction/webwriter-video-interaction.component";
import { formatTime } from "../../utils/timeFormatter";
/**
* Class containing the video player as well as all the logic for video playback, interactive elements, controls, file input, and more.
* This class extends the `LitElementWw` class.
*/
export class WebwriterInteractiveVideo extends LitElementWw {
/**
* Returns an object that maps custom element names to their corresponding classes.
* These custom elements can be used within the scope of the `webwriter-interactive-video` component.
*
* @returns An object mapping custom element names to their corresponding classes.
*/
static get scopedElements() {
return {
"sl-range": SlRange,
"sl-icon": SlIcon,
"interactive-video-options": InteractiveVideoOptions,
"video-input-overlay": VideoInputOverlay,
"video-controls-bar": VideoControlsBar,
"video-chapter-drawer": VideoChapterDrawer,
"interactions-progress-bar": InteractionsProgressBar,
"webwriter-video-interaction": WwVideoInteraction,
};
}
/**
* The styles for the webwriter-interactive-video component.
*/
static styles = [styles];
/**
*
*/
accessor videoContext: InteractiveVideoContext =
new InteractiveVideoContext();
accessor videoInteractions;
accessor tabIndex = -1;
accessor videoDurationFormatted: string = "00:00";
accessor lastTimeupdate: number = 0;
accessor isDragging = false;
accessor videoElement: HTMLVideoElement;
accessor videoControlsBar: VideoControlsBar;
accessor interactionsProgressBar: InteractionsProgressBar;
accessor progressBar;
accessor chaptersDrawer: VideoChapterDrawer;
private observer: MutationObserver | null = null;
/**
* Called when the element is first connected to the document's DOM.
* @remarks
* Adds event listeners for fullscreen changes.
* Also builds the interaction configuration and renders the chapters list, if available.
*/
connectedCallback() {
super.connectedCallback();
this.videoContext.videoLoaded = false;
this.updateContext();
document.addEventListener("fullscreenchange", this.handleFullscreenChange);
this.observer = new MutationObserver(this.monitorSlot);
this.observer.observe(this, { childList: true });
}
/*
* Sets up some default values for the overlay
*/
firstUpdated() {
this.updateContext();
if (this.videoContext.videoBase64) {
this.setupVideo(this.videoContext.videoBase64);
} else if (this.videoContext.videoURL) {
this.setupVideo(this.videoContext.videoURL);
}
this.updateBaublePositions();
}
/**
* Renders the component.
*
* @returns either HTML for either the widget or the file input area, depending on whether a video has already been selected.
*/
render() {
return html`
<div id="widget">
<!-- VIDEO INPUT -->
${!this.hasVideo()
? html`
<video-input-overlay
=${(e: CustomEvent) => this.setupVideo(e.detail.src)}
></video-input-overlay>
`
: null}
<div id="container-vertical">
<!-- VIDEO ELEMENT -->
<div
id="container-video"
=${(e: CustomEvent) => {
this.interactionClicked(e.detail.id);
}}
=${this.handleVideoClick}
=${() => this.updateContext()}
>
<video id="video"></video>
${this.videoContext.videoLoaded ? this.showPopups() : null}
<slot></slot>
</div>
<!-- CONTROLS -->
<div id="controls">
<!-- Baubles // Bubbles on Progress Bar -->
<interactions-progress-bar
style="outline: none"
contenteditable=${this.isContentEditable}
=${() =>
this.addVideoInteraction(this.videoInteractions.length)}
=${(e: CustomEvent) =>
this.baubleClicked(e.detail.id)}
=${(e: CustomEvent) =>
this.changeInteractionStartTime(
e.detail.newTime,
e.detail.index
)}
=${(e: CustomEvent) =>
this.jumpToChapter(e.detail.startTime)}
></interactions-progress-bar>
<!-- Progress Bar -->
<sl-range
id="progress-bar"
-change=${this.handleProgressChange}
></sl-range>
<!-- Video Controls Bar -->
<video-controls-bar
style="outline: none"
contenteditable=${this.isContentEditable}
=${(e: CustomEvent) =>
this.handleVolumeChange(e.detail.value)}
=${() => this.toggleMute()}
=${() => this.togglePlayVideo()}
=${() => this.toggleChaptersDrawer()}
=${(e: CustomEvent) =>
this.changePlaybackRate(e.detail.value)}
=${() => this.getCurrentChapter()}
></video-controls-bar>
</div>
</div>
<!-- DRAWERS -->
<!-- Video Chapter Drawer -->
<video-chapter-drawer
style="z-index: 51"
contenteditable=${this.isContentEditable}
=${() =>
this.chaptersDrawer.addChapter(this.videoElement.duration)}
=${() => this.updateContext()}
=${(e: CustomEvent) =>
this.jumpToChapter(e.detail.startTime)}
></video-chapter-drawer>
</div>
<!-- OPTIONS PANEL -->
<interactive-video-options
contenteditable=${this.isContentEditable}
style="outline: none"
part="options"
class="author-only"
=${() => this.updateContext()}
=${() => this.updateBaublePositions()}
></interactive-video-options>
`;
}
/**
* Renders the overlay elements for the video.
*
* @returns {Array<TemplateResult>} of any overlay elements that need to be displayed at the current video time
* @remarks
* this checks video time to see if an overlay should be displayed and renders those from the videoData map.
*/
showPopups() {
if (!this.videoContext.showOverlay && this.isContentEditable) return;
Array.from(this.videoInteractions).map((interaction) => {
if (
this.videoElement.currentTime >=
(interaction as WwVideoInteraction).startTime &&
this.videoElement.currentTime <=
(interaction as WwVideoInteraction).endTime
) {
(interaction as HTMLElement).style.display = "block";
if ((interaction as WwVideoInteraction).initialPause === "false") {
this.pauseVideo();
(interaction as WwVideoInteraction).initialPause = "true";
(interaction as HTMLElement).setAttribute("initialPause", "true");
}
} else {
(interaction as HTMLElement).style.display = "none";
if ((interaction as WwVideoInteraction).initialPause) {
(interaction as WwVideoInteraction).initialPause = "false";
(interaction as HTMLElement).setAttribute("initialPause", "false");
}
}
});
}
/*
*/
updateContext() {
this.setAttribute("videoContext", JSON.stringify(this.videoContext));
this.requestUpdate();
}
/*
*/
jumpToChapter(time) {
this.videoElement.currentTime = time;
}
//
//
//
interactionClicked(id) {
this.videoContext.selectedInteractionID = id;
const interaction = this.videoInteractions.filter(
(interaction) => Number(interaction.id) === Number(id)
)[0] as WwVideoInteraction;
// interaction.focus();
this.updateContext();
}
//
//
//
baubleClicked(id) {
this.pauseVideo();
const slottedInteraction = this.videoInteractions.filter(
(interaction) => Number(interaction.id) === Number(id)
)[0] as WwVideoInteraction;
this.videoElement.currentTime = slottedInteraction.startTime;
this.requestUpdate();
this.interactionClicked(id);
}
//
//
//
changeInteractionStartTime(newTime, index) {
const slottedInteraction = this.videoInteractions.filter(
(interaction) => Number(interaction.id) === Number(index)
)[0] as WwVideoInteraction;
slottedInteraction.startTime = newTime;
slottedInteraction.setAttribute("starTime", newTime);
slottedInteraction.endTime = newTime + 5;
slottedInteraction.setAttribute("endTime", String(newTime + 5));
this.videoElement.currentTime = slottedInteraction.startTime;
this.updateContext();
}
/**
* Retrieves the current chapter based on the current time of the video.
* @returns The current chapter object containing the title and start time, or null if there are no chapters or the current time is before the start of any chapter.
*/
getCurrentChapter() {
if (!this.videoContext.hasChapters) {
this.videoControlsBar.currentChapter = null;
return;
}
const chapters = JSON.parse(this.videoContext.chapterConfig);
for (let i = chapters.length - 1; i >= 0; i--) {
if (this.videoElement.currentTime >= chapters[i].startTime) {
this.videoControlsBar.currentChapter = chapters[i];
return;
}
}
this.videoControlsBar.currentChapter = null;
return;
}
/**
* Calculates the offset based on the given time.
*
* @param time - The time in seconds.
* @returns The calculated offset.
*/
calculateOffset(time: number): number {
if (!this.videoContext.videoLoaded || !this.videoElement) return;
const rect = this.videoElement.getBoundingClientRect();
return (time / this.videoElement.duration) * 0.95 * rect.width;
}
/**
* Checks whether a video is already existing on load.
* @returns whether a video exists (either base64 or URL, used for deciding whether to show file input area or video element)
*/
hasVideo = (): boolean => {
if (this.videoContext.videoBase64 || this.videoContext.videoURL) {
return true;
}
return false;
};
//
//
//
addVideoInteraction(id) {
// Case: User selected "Replace Interaction" from the dropdown
// create interaction and set videodata
const interaction = document.createElement(
"webwriter-video-interaction"
) as WwVideoInteraction;
const videoRect = this.videoElement.getBoundingClientRect();
const interactionWidth = 300; // Adjust based on actual element size
const interactionHeight = 200; // Adjust based on actual element size
interaction.style.position = "absolute";
interaction.style.top = `${videoRect.height / 2 - interactionHeight / 2}px`;
interaction.style.left = `${videoRect.width / 2 - interactionWidth / 2}px`;
interaction.style.width = `${interactionWidth}px`;
interaction.style.height = `${interactionHeight}px`;
this.appendChild(interaction);
interaction.setAttribute("id", `${id}`);
interaction.setAttribute("startTime", `${this.videoElement.currentTime}`);
interaction.setAttribute("endTime", `${this.videoElement.currentTime + 5}`);
interaction.setAttribute("color", `#ffffff`);
//to force re-rendering such that bauble is displayed
this.updateContext();
}
/**
* Handles the click event on the video element.
*
* @param e - The MouseEvent object representing the click event.
*/
handleVideoClick = (e: MouseEvent) => {
const clickedElement = e.target as HTMLElement;
// Check if the clicked element is inside the slot or is an interaction element
if (clickedElement.closest("webwriter-video-interaction")) {
// Prevent further action if it's a specific interaction element
e.stopImmediatePropagation();
e.preventDefault();
return;
}
if (!this.videoContext.videoLoaded) return;
e.stopPropagation();
this.videoContext.selectedInteractionID = -1;
this.updateContext();
};
/**
* Toggles the chapters drawer open or closed.
*/
toggleChaptersDrawer() {
this.chaptersDrawer.drawer.open = !this.chaptersDrawer.drawer.open;
}
/**
* Handles the change event for the volume slider and sets the video volume and button icon accordingly.
*
* @param e - The custom event object.
*/
handleVolumeChange(value) {
this.videoElement.volume = value / 100;
}
/**
* Handles the click event for the mute button.
*
* @param e - The custom event object.
*/
toggleMute() {
if (this.videoElement.muted) {
this.videoElement.muted = false;
} else {
this.videoElement.muted = true;
}
}
//
//
//
changePlaybackRate(value) {
this.videoElement.playbackRate = value;
}
/**
* Updates the positions of the baubles in the widget.
*/
updateBaublePositions() {
this.interactionsProgressBar.updateBaublePositions();
this.updateContext();
}
/**
* Handles the time update event of the video player and check whether there are interactions to be displayed by comparing current call time to last.
* This way we dont skip any interactions and dont fire twice since this is called inconsistently.
*
* @param e - The custom event object.
*/
handleTimeUpdate = (e: CustomEvent) => {
this.lastTimeupdate = this.videoElement.currentTime;
this.progressBar.value =
(this.videoElement.currentTime / this.videoElement.duration) * 100;
this.videoControlsBar.handleTimeUpdate(
this.lastTimeupdate,
this.videoDurationFormatted
);
this.getCurrentChapter();
if (this.videoElement.currentTime >= this.videoElement.duration) {
this.videoControlsBar.playButton.setAttribute("src", `${playerPlay}`);
}
};
/**
* Handles the progress change event and updates the video's progress bar and time stamp based on the current video time.
*
* @param e - The custom event object.
*/
handleProgressChange = (e: CustomEvent) => {
this.showPopups();
const progressBar = e.target as SlRange;
let currentTime = (progressBar.value / 100) * this.videoElement.duration;
this.videoElement.currentTime = Math.floor(currentTime);
this.videoControlsBar.timeStamp.value =
formatTime(currentTime) + " / " + this.videoDurationFormatted;
};
/**
* Handles the fullscreen change event by repositioning the baubles to fit the new video size.
*/
handleFullscreenChange = () => {
//this.updateBaublePositions();
};
/**
* Sets up the video element with the provided source and attaches event listeners to the video object.
*
* @param src - The source URL of the video.
*/
setupVideo(src: string) {
this.updateContext();
this.videoElement.src = src;
this.videoElement.style.width = "100%";
this.videoElement.addEventListener(
"loadedmetadata",
this.handleMetadataLoaded
);
this.videoElement.addEventListener(
"canplaythrough",
this.handleCanPlayThrough
);
this.videoElement.addEventListener("timeupdate", this.handleTimeUpdate);
}
/**
* Toggles the playback of the video. If the video has ended, it resets the current time to 0.
* @remarks
* Also changes the play button icon to 'pause' if the video is playing, and 'play' if the video is paused.
*/
togglePlayVideo() {
if (!this.videoContext.videoLoaded) return;
if (this.videoElement.ended) {
this.videoElement.currentTime = 0;
}
if (this.videoElement.paused) {
this.playVideo();
} else {
this.pauseVideo();
}
}
//
//
//
playVideo() {
this.videoElement.play();
this.videoControlsBar.playButton.setAttribute("src", `${playerPause}`);
// Add the scaling animation class to the button
this.videoControlsBar.playButton.classList.add("scale-animation");
// Remove the animation class after it's done
setTimeout(() => {
this.videoControlsBar.playButton.classList.remove("scale-animation");
}, 300); // Adjust timing to match animation duration
}
//
//
//
pauseVideo() {
this.videoElement.pause();
this.videoControlsBar.playButton.setAttribute("src", `${playerPlay}`);
// Add the scaling animation class to the button
this.videoControlsBar.playButton.classList.add("scale-animation");
// Remove the animation class after it's done
setTimeout(() => {
this.videoControlsBar.playButton.classList.remove("scale-animation");
}, 300); // Adjust timing to match animation duration
}
/**
* Calculates the contrast color based on the given hex color.
* @param hexColor - The hex color value.
* @returns Either black or White depending on contrast with the given color.
*/
getContrastColor(hexColor: string): string {
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? "#000000" : "#ffffff";
}
/**
* Handles the click event on the overlay element by calling the clickEventHelper function with the id of the interaction.
*
* @param event - The MouseEvent object representing the click event.
*/
handleOverlayClicked = (event: MouseEvent) => {
event.stopPropagation();
if (this.isDragging) {
this.isDragging = false;
return;
}
// this.clickEventHelper(
// parseInt((event.currentTarget as HTMLElement).id.split("-")[1])
// );
};
/**
* Handles the 'canplaythrough' event of the video element.
*
* This function is called when the video can be played through without interruption.
* @remarks
* It performs various actions such as enabling/disabling the addButton, setting the progressBar value to 0,
* setting the video volume to 0.1, setting the volumeSlider value to 10, and initializing the chapterConfig
* if it is empty.
* The Timeout was necessary to ensure that the elements are rendered before the actions are performed.
*/
handleCanPlayThrough = () => {
if (this.videoContext.videoLoaded) return;
this.videoContext.videoLoaded = true;
this.updateContext();
setTimeout(() => {
if (this.progressBar) {
this.progressBar.value = 0;
}
if (this.videoElement) {
this.videoElement.volume = 0.1;
}
if (this.videoControlsBar.volumeSlider) {
this.videoControlsBar.volumeSlider.value = 10;
}
this.updateContext();
this.requestUpdate();
}, 0);
};
/**
* Handles the loaded metadata event of the video element.
* @remarks
* This function is called when the metadata of the video is loaded to set up things we dont have to wait for the video to load fully for.
*/
handleMetadataLoaded = () => {
this.videoDurationFormatted = formatTime(this.videoElement.duration);
setTimeout(() => {
if (this.progressBar) {
this.progressBar.max = 100;
this.progressBar.tooltipFormatter = (value: number) => {
return formatTime(
Math.floor((value / 100) * this.videoElement.duration)
);
};
}
if (this.videoControlsBar.timeStamp) {
this.videoControlsBar.timeStamp.innerHTML = `00:00 / ${this.videoDurationFormatted}`;
}
this.requestUpdate();
}, 0);
};
/*
*/
private monitorSlot = (mutationList: MutationRecord[]) => {
mutationList.forEach((mutation) => {
if (mutation.type === "childList") {
mutation.removedNodes.forEach((node) => {
const nodeName = (node as HTMLElement).nodeName.toLowerCase();
const isWidget = (node as HTMLElement).classList.contains(
"ww-widget"
);
// "ProseMirror-selectednode" css class confirms that the element is actively selected by the user
const isSelectedNode = (node as HTMLElement).classList.contains(
"ProseMirror-selectednode"
);
if (isWidget && isSelectedNode) {
if (nodeName === "webwriter-video-interaction") {
this.updateContext();
this.requestUpdate();
}
}
});
}
});
};
}