UNPKG

@webwriter/interactive-video

Version:

(WIP) Enhance learning by adding interactive content in popups to videos for an engaging, interactive experience.

325 lines (289 loc) 10.5 kB
import { html, css, LitElement, PropertyValues } from "lit"; import { LitElementWw } from "@webwriter/lit"; import { customElement, property, query } from "lit/decorators.js"; import { SlDrawer, SlInput, SlButton, SlIcon, SlIconButton, SlSwitch, } from "@shoelace-style/shoelace"; import "@shoelace-style/shoelace/dist/themes/light.css"; import { videoContext, InteractiveVideoContext, } from "../../utils/interactive-video-context"; import { formatTime, parseTime } from "../../utils/timeFormatter"; import { consume } from "@lit/context"; import styles from "./video-chapter-drawer.styles"; import bookmarks from "@tabler/icons/outline/plus.svg"; import jumpTo from "@tabler/icons/filled/player-track-next.svg"; import trash from "@tabler/icons/outline/trash.svg"; //Tabler export class VideoChapterDrawer extends LitElementWw { @consume({ context: videoContext, subscribe: true }) accessor videoContext: InteractiveVideoContext; @query("#chapters-drawer") accessor drawer: SlDrawer; /* */ static get scopedElements() { return { "sl-drawer": SlDrawer, "sl-input": SlInput, "sl-button": SlButton, "sl-icon": SlIcon, "sl-icon-button": SlIconButton, "sl-switch": SlSwitch, }; } //import CSS static styles = [styles]; /* */ /* */ firstUpdated() {} /** * Renders the chapters drawer. * * @returns {TemplateResult} the rendered HTML for the chapters drawer, filled with the chapters list and an add chapter button (if content is editable). */ render() { const chapters = JSON.parse(this.videoContext.chapterConfig); return html` <sl-drawer contained label="Chapters" id="chapters-drawer"> <!-- class="author-only" --> ${this.isContentEditable ? html` <div style="display: flex; flex-direction: row; align-items: center; " > <sl-switch ?checked=${this.videoContext.hasChapters} @sl-change=${this.handleHasChaptersChange} >Chapters</sl-switch > <sl-button style="margin-left: auto" @click=${() => this.dispatchEvent( new CustomEvent("addChapter", { bubbles: true, composed: true, }) )} > <sl-icon slot="prefix" src=${bookmarks}></sl-icon> Add</sl-button > </div> ` : null} <ul class="chapter-list"> ${chapters.map( (chapter, index) => html` <li class="chapter-item"> ${this.isContentEditable ? html` <!-- Render Chapter Title--> <sl-input size="small" label="Title" value=${chapter.title} @sl-change=${(e) => this.updateChapterTitle(index, e.target.value)} > </sl-input> <!-- Render chapter card with an input field to change start time--> <sl-input pill class="timeInput" size="small" label="Start" style="width: 65px" value=${formatTime(chapter.startTime)} @sl-change=${(e) => this.handleTimeInputChange(e, index)} ?disabled=${index === 0 ? true : false} ></sl-input> <!-- Delete button for all but the first chapter --> ${index > 0 ? html` <div style="display: flex; flex-direction: row; align-items: center;" > <sl-button variant="text" style="margin-right: auto" @click=${() => this.jumpToChapter(index)} >Skip to Chapter <sl-icon slot="prefix" src=${jumpTo}></sl-icon> </sl-button> <sl-icon-button src=${trash} @click=${() => this.deleteChapter(index)} ></sl-icon-button> </div>` : html`<sl-button variant="text" @click=${() => this.jumpToChapter(index)} >Skip to Chapter <sl-icon slot="prefix" src=${jumpTo}></sl-icon> </sl-button>`} ` : html` <!-- If content is not editable, just display chapter information --> <div class="chapter-info"> <p><strong>${chapter.title}</strong></p> <div style="display: flex; flex-direction: row; align-items: center; " > <p>${formatTime(chapter.startTime)}</p> <sl-button variant="text" @click=${() => this.jumpToChapter(index)} >Skip to Chapter <sl-icon slot="prefix" src=${jumpTo}></sl-icon> </sl-button> </div> </div> `} </li> ` )} </ul> </sl-drawer> `; } /** * Updates the title of a chapter at the specified index. * * @param index - The index of the chapter to update. * @param newTitle - The new title for the chapter. */ updateChapterTitle(index: number, newTitle: string) { const chapters = JSON.parse(this.videoContext.chapterConfig); chapters[index].title = newTitle; this.updateChapters(chapters); } /** * Deletes a chapter from the chapter configuration. * * @param index - The index of the chapter to delete. */ deleteChapter(index: number) { const chapters = JSON.parse(this.videoContext.chapterConfig); chapters.splice(index, 1); this.updateChapters(chapters); } /** * Jumps to a specific chapter in the interactive video. * @param index - The index of the chapter to jump to. */ jumpToChapter(index: number) { const chapters = JSON.parse(this.videoContext.chapterConfig); if (chapters[index]) { this.dispatchEvent( new CustomEvent("jumpToChapter", { detail: { startTime: chapters[index].startTime }, bubbles: true, composed: true, }) ); } } /** * Adds a new chapter to the interactive video. * The new chapter is appended to the end of the chapter list. */ addChapter(duration) { const chapters = JSON.parse(this.videoContext.chapterConfig); const lastChapter = chapters[chapters.length - 1]; const newStartTime = lastChapter ? Math.min(lastChapter.startTime + 60, duration) : 0; chapters.push({ title: `Chapter ${chapters.length + 1}`, startTime: newStartTime, }); this.updateChapters(chapters); } /** * Updates the chapters of the interactive video, sorting them by start time. * * @param chapters - An array of chapters to update. */ updateChapters(chapters: any[]) { chapters.sort((a, b) => a.startTime - b.startTime); this.videoContext.chapterConfig = JSON.stringify(chapters); this.dispatchEvent( new CustomEvent("updateContext", { bubbles: true, composed: true, }) ); this.requestUpdate(); } /** * Handles the time input change event. * * @param e - The custom event object. * @param index - The optional index of the chapter. * @remarks * This function is called when the time input is changed. It parses the input value and updates the corresponding time value. * If the index is provided, it updates the chapter time; otherwise, it updates the bauble time. */ handleTimeInputChange = (e: CustomEvent, index?: number) => { const input = e.target as SlInput; const newTime = parseTime(input.value); if (newTime !== null) { // an index is only passed if the time input is for a chapter if (index !== undefined) { //update chapter time this.updateChapterTime(index, newTime); } input.value = formatTime(newTime); } else { input.helpText = "Invalid time format. Use hh:mm:ss or mm:ss"; } // change bauble positions to reflect new time and request an update // this.updateBaublePositions(); }; /** * Updates the start time of a chapter at the specified index. * * @param index - The index of the chapter to update. * @param newTime - The new start time for the chapter. */ updateChapterTime(index: number, newTime: number) { if (index === 0) return; let chapters = JSON.parse(this.videoContext.chapterConfig); chapters[index].startTime = newTime; this.updateChapters(chapters); } /** * Handles the change event when the "hasChapters" checkbox is toggled. * @param e - The custom event object. */ handleHasChaptersChange = (e: CustomEvent) => { const target = e.target as SlSwitch; this.videoContext.hasChapters = target.checked; if ( this.videoContext.hasChapters && JSON.parse(this.videoContext.chapterConfig).length === 0 ) { this.videoContext.chapterConfig = JSON.stringify([ { title: "Chapter 1", startTime: 0, }, ]); } this.dispatchEvent( new CustomEvent("updateContext", { bubbles: true, composed: true, }) ); }; }