UNPKG

@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
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]; /** * */ @provide({ context: videoContext, }) @property({ type: Object, attribute: true, reflect: true, }) accessor videoContext: InteractiveVideoContext = new InteractiveVideoContext(); @queryAssignedElements({ flatten: true, selector: "webwriter-video-interaction", }) accessor videoInteractions; @property({ type: Number, attribute: true, reflect: true }) accessor tabIndex = -1; @property({ type: String }) accessor videoDurationFormatted: string = "00:00"; @property({ type: Number }) accessor lastTimeupdate: number = 0; @property({ type: Boolean }) accessor isDragging = false; @query("#video") accessor videoElement: HTMLVideoElement; @query("video-controls-bar") accessor videoControlsBar: VideoControlsBar; @query("interactions-progress-bar") accessor interactionsProgressBar: InteractionsProgressBar; @query("#progress-bar") accessor progressBar; @query("video-chapter-drawer") 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 @setupVideo=${(e: CustomEvent) => this.setupVideo(e.detail.src)} ></video-input-overlay> ` : null} <div id="container-vertical"> <!-- VIDEO ELEMENT --> <div id="container-video" @interactionClicked=${(e: CustomEvent) => { this.interactionClicked(e.detail.id); }} @click=${this.handleVideoClick} @updateContext=${() => 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} @addInteraction=${() => this.addVideoInteraction(this.videoInteractions.length)} @interactionBaubleClicked=${(e: CustomEvent) => this.baubleClicked(e.detail.id)} @changeInteractionStartTime=${(e: CustomEvent) => this.changeInteractionStartTime( e.detail.newTime, e.detail.index )} @jumpToChapter=${(e: CustomEvent) => this.jumpToChapter(e.detail.startTime)} ></interactions-progress-bar> <!-- Progress Bar --> <sl-range id="progress-bar" @sl-change=${this.handleProgressChange} ></sl-range> <!-- Video Controls Bar --> <video-controls-bar style="outline: none" contenteditable=${this.isContentEditable} @volumeChange=${(e: CustomEvent) => this.handleVolumeChange(e.detail.value)} @toggleMute=${() => this.toggleMute()} @startstopVideo=${() => this.togglePlayVideo()} @toggleChaptersDrawer=${() => this.toggleChaptersDrawer()} @playbackRateChange=${(e: CustomEvent) => this.changePlaybackRate(e.detail.value)} @getCurrentChapter=${() => this.getCurrentChapter()} ></video-controls-bar> </div> </div> <!-- DRAWERS --> <!-- Video Chapter Drawer --> <video-chapter-drawer style="z-index: 51" contenteditable=${this.isContentEditable} @addChapter=${() => this.chaptersDrawer.addChapter(this.videoElement.duration)} @updateContext=${() => this.updateContext()} @jumpToChapter=${(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" @updateContext=${() => this.updateContext()} @updateBaublePositions=${() => 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(); } } }); } }); }; }