import videojs, { VideoJsPlayer } from "video.js"; import "./markers.css"; import CryptoJS from "crypto-js"; export interface IMarker { title: string; seconds: number; end_seconds?: number | null; } interface IMarkersOptions { markers?: IMarker[]; } class MarkersPlugin extends videojs.getPlugin("plugin") { private markers: IMarker[] = []; private markerDivs: { dot?: HTMLDivElement; range?: HTMLDivElement; containedRanges?: HTMLDivElement[]; }[] = []; private markerTooltip: HTMLElement | null = null; private defaultTooltip: HTMLElement | null = null; private layerHeight: number = 9; private tagColors: { [tag: string]: string } = {}; constructor(player: VideoJsPlayer) { super(player); player.ready(() => { const tooltip = videojs.dom.createEl("div") as HTMLElement; tooltip.className = "vjs-marker-tooltip"; tooltip.style.visibility = "hidden"; const parent = player .el() .querySelector(".vjs-progress-holder .vjs-mouse-display"); if (parent) parent.appendChild(tooltip); this.markerTooltip = tooltip; this.defaultTooltip = player .el() .querySelector( ".vjs-progress-holder .vjs-mouse-display .vjs-time-tooltip" ); }); } private showMarkerTooltip(title: string, layer: number = 0) { if (!this.markerTooltip) return; this.markerTooltip.innerText = title; this.markerTooltip.style.right = `${-this.markerTooltip.clientWidth / 2}px`; this.markerTooltip.style.top = `-${this.layerHeight * layer + 50}px`; this.markerTooltip.style.visibility = "visible"; if (this.defaultTooltip) this.defaultTooltip.style.visibility = "hidden"; } private hideMarkerTooltip() { if (this.markerTooltip) this.markerTooltip.style.visibility = "hidden"; if (this.defaultTooltip) this.defaultTooltip.style.visibility = "visible"; } addDotMarker(marker: IMarker) { const duration = this.player.duration(); const markerSet: { dot?: HTMLDivElement; range?: HTMLDivElement; } = {}; const seekBar = this.player.el().querySelector(".vjs-progress-control"); markerSet.dot = videojs.dom.createEl("div") as HTMLDivElement; markerSet.dot.className = "vjs-marker-dot"; if (duration) { markerSet.dot.style.left = `calc(${ (marker.seconds / duration) * 100 }% - 3px)`; } // Add event listeners to dot markerSet.dot.addEventListener("click", () => this.player.currentTime(marker.seconds) ); markerSet.dot.toggleAttribute("marker-tooltip-shown", true); // Set background color based on tag (if available) if (marker.title && this.tagColors[marker.title]) { markerSet.dot.style.backgroundColor = this.tagColors[marker.title]; } markerSet.dot.addEventListener("mouseenter", () => { this.showMarkerTooltip(marker.title); markerSet.dot?.toggleAttribute("marker-tooltip-shown", true); }); markerSet.dot.addEventListener("mouseout", () => { this.hideMarkerTooltip(); markerSet.dot?.toggleAttribute("marker-tooltip-shown", false); }); if (seekBar) { seekBar.appendChild(markerSet.dot); } } private renderRangeMarkers(markers: IMarker[], layer: number) { const duration = this.player.duration(); const seekBar = this.player.el().querySelector(".vjs-progress-control"); if (!seekBar || !duration) return; markers.forEach((marker) => { this.renderRangeMarker(marker, layer, duration, seekBar); }); } private renderRangeMarker( marker: IMarker, layer: number, duration: number, seekBar: Element ) { if (!marker.end_seconds) return; const markerSet: { dot?: HTMLDivElement; range?: HTMLDivElement; } = {}; const rangeDiv = videojs.dom.createEl("div") as HTMLDivElement; rangeDiv.className = "vjs-marker-range"; const startPercent = (marker.seconds / duration) * 100; const endPercent = (marker.end_seconds / duration) * 100; let width = endPercent - startPercent; // Ensure the width is at least 8px const minWidth = (10 / seekBar.clientWidth) * 100; // Convert 8px to percentage if (width < minWidth) { width = minWidth; } rangeDiv.style.left = `${startPercent}%`; rangeDiv.style.width = `${width}%`; rangeDiv.style.bottom = `${layer * this.layerHeight}px`; // Adjust height based on layer rangeDiv.style.display = "none"; // Initially hidden // Set background color based on tag (if available) if (marker.title && this.tagColors[marker.title]) { rangeDiv.style.backgroundColor = this.tagColors[marker.title]; } markerSet.range = rangeDiv; markerSet.range.style.display = "block"; markerSet.range.addEventListener("pointermove", (e) => { e.stopPropagation(); }); markerSet.range.addEventListener("pointerover", (e) => { e.stopPropagation(); }); markerSet.range.addEventListener("pointerout", (e) => { e.stopPropagation(); }); markerSet.range.addEventListener("mouseenter", () => { this.showMarkerTooltip(marker.title, layer); markerSet.range?.toggleAttribute("marker-tooltip-shown", true); }); markerSet.range.addEventListener("mouseout", () => { this.hideMarkerTooltip(); markerSet.range?.toggleAttribute("marker-tooltip-shown", false); }); seekBar.appendChild(rangeDiv); this.markers.push(marker); this.markerDivs.push(markerSet); } addRangeMarkers(markers: IMarker[]) { let remainingMarkers = [...markers]; let layerNum = 0; while (remainingMarkers.length > 0) { // Get the set of markers that currently have the highest total duration that don't overlap. We do this layer by layer to prioritize filling // the lower layers when possible const mwis = this.findMWIS(remainingMarkers); if (!mwis.length) break; this.renderRangeMarkers(mwis, layerNum); remainingMarkers = remainingMarkers.filter( (marker) => !mwis.includes(marker) ); layerNum++; } } // Use dynamic programming to find maximum weight independent set (ie the set of markers that have the highest total duration that don't overlap) private findMWIS(markers: IMarker[]): IMarker[] { if (!markers.length) return []; // Sort markers by end time markers = markers .slice() .sort((a, b) => (a.end_seconds || 0) - (b.end_seconds || 0)); const n = markers.length; // Compute p(j) for each marker. This is the index of the marker that has the highest end time that doesn't overlap with marker j const p: number[] = new Array(n).fill(-1); for (let j = 0; j < n; j++) { for (let i = j - 1; i >= 0; i--) { if ((markers[i].end_seconds || 0) <= markers[j].seconds) { p[j] = i; break; } } } // Initialize M[j] // Compute M[j] for each marker. This is the maximum total duration of markers that don't overlap with marker j const M: number[] = new Array(n).fill(0); for (let j = 0; j < n; j++) { const include = (markers[j].end_seconds || 0) - markers[j].seconds + (M[p[j]] || 0); const exclude = j > 0 ? M[j - 1] : 0; M[j] = Math.max(include, exclude); } // Reconstruct optimal solution const findSolution = (j: number): IMarker[] => { if (j < 0) return []; const include = (markers[j].end_seconds || 0) - markers[j].seconds + (M[p[j]] || 0); const exclude = j > 0 ? M[j - 1] : 0; if (include >= exclude) { return [...findSolution(p[j]), markers[j]]; } else { return findSolution(j - 1); } }; return findSolution(n - 1); } removeMarker(marker: IMarker) { const i = this.markers.indexOf(marker); if (i === -1) return; this.markers.splice(i, 1); const markerSet = this.markerDivs.splice(i, 1)[0]; if (markerSet.dot?.hasAttribute("marker-tooltip-shown")) { this.hideMarkerTooltip(); } markerSet.dot?.remove(); if (markerSet.range) markerSet.range.remove(); } removeMarkers(markers: IMarker[]) { markers.forEach(this.removeMarker, this); } clearMarkers() { this.removeMarkers([...this.markers]); } // Implementing the findColors method async findColors(tagNames: string[]) { // Compute base hues for each tag const baseHues: { [tag: string]: number } = {}; for (const tag of tagNames) { baseHues[tag] = await this.computeBaseHue(tag); } // Adjust hues to avoid similar colors const adjustedHues = this.adjustHues(baseHues); // Convert adjusted hues to colors and store in tagColors dictionary for (const tag of tagNames) { this.tagColors[tag] = this.hueToColor(adjustedHues[tag]); } } // Helper methods translated from Python // Compute base hue from tag name private async computeBaseHue(tag: string): Promise { const hash = CryptoJS.SHA256(tag); const hashHex = hash.toString(CryptoJS.enc.Hex); const hashInt = BigInt(`0x${hashHex}`); const baseHue = Number(hashInt % BigInt(360)); // Map to [0, 360) return baseHue; } // Calculate minimum acceptable hue difference based on number of tags private calculateDeltaMin(N: number): number { const maxDeltaNeeded = 35; let scalingFactor: number; if (N <= 4) { scalingFactor = 0.8; } else if (N <= 10) { scalingFactor = 0.6; } else { scalingFactor = 0.4; } const deltaMin = Math.min((360 / N) * scalingFactor, maxDeltaNeeded); return deltaMin; } // Adjust hues to ensure minimum difference private adjustHues(baseHues: { [tag: string]: number }): { [tag: string]: number; } { const adjustedHues: { [tag: string]: number } = {}; const tags = Object.keys(baseHues); const N = tags.length; const deltaMin = this.calculateDeltaMin(N); // Sort the tags by base hue const sortedTags = tags.sort((a, b) => baseHues[a] - baseHues[b]); // Get sorted base hues const baseHuesSorted = sortedTags.map((tag) => baseHues[tag]); // Unwrap hues to handle circular nature const unwrappedHues = [...baseHuesSorted]; for (let i = 1; i < N; i++) { if (unwrappedHues[i] <= unwrappedHues[i - 1]) { unwrappedHues[i] += 360; // Unwrap by adding 360 degrees } } // Adjust hues to ensure minimum difference for (let i = 1; i < N; i++) { const requiredHue = unwrappedHues[i - 1] + deltaMin; if (unwrappedHues[i] < requiredHue) { unwrappedHues[i] = requiredHue; // Adjust hue minimally } } // Handle wrap-around difference const endGap = unwrappedHues[0] + 360 - unwrappedHues[N - 1]; if (endGap < deltaMin) { // Adjust first and last hues minimally to increase end gap const adjustmentNeeded = (deltaMin - endGap) / 2; // Adjust the first hue backward, ensure it doesn't go below other hues unwrappedHues[0] = Math.max( unwrappedHues[0] - adjustmentNeeded, unwrappedHues[1] - 360 + deltaMin ); // Adjust the last hue forward unwrappedHues[N - 1] += adjustmentNeeded; } // Wrap adjusted hues back to [0, 360) const adjustedHuesList = unwrappedHues.map((hue) => hue % 360); // Map adjusted hues back to tags for (let i = 0; i < N; i++) { adjustedHues[sortedTags[i]] = adjustedHuesList[i]; } return adjustedHues; } // Convert hue to RGB color in hex format private hueToColor(hue: number): string { // Convert hue from degrees to [0, 1) const hueNormalized = hue / 360.0; const saturation = 0.65; const value = 0.95; const rgb = this.hsvToRgb(hueNormalized, saturation, value); const alpha = 0.6; // Set the desired alpha value here const rgbColor = `#${this.toHex(rgb[0])}${this.toHex(rgb[1])}${this.toHex( rgb[2] )}${this.toHex(Math.round(alpha * 255))}`; return rgbColor; } // Convert HSV to RGB private hsvToRgb(h: number, s: number, v: number): [number, number, number] { const i = Math.floor(h * 6); const f = h * 6 - i; const p = v * (1 - s); const q = v * (1 - f * s); const t = v * (1 - (1 - f) * s); let r, g, b; switch (i % 6) { case 0: r = v; g = t; b = p; break; case 1: r = q; g = v; b = p; break; case 2: r = p; g = v; b = t; break; case 3: r = p; g = q; b = v; break; case 4: r = t; g = p; b = v; break; case 5: r = v; g = p; b = q; break; default: r = v; g = t; b = p; break; } return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; } // Convert a number to two-digit hex string private toHex(value: number): string { return value.toString(16).padStart(2, "0"); } } videojs.registerPlugin("markers", MarkersPlugin); /* eslint-disable @typescript-eslint/naming-convention */ declare module "video.js" { interface VideoJsPlayer { markers: () => MarkersPlugin; } interface VideoJsPlayerPluginOptions { markers?: IMarkersOptions; } } export default MarkersPlugin;