diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index d83307fca..47ce7af78 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -39,6 +39,7 @@ "base64-blob": "^1.4.1", "bootstrap": "^4.6.2", "classnames": "^2.3.2", + "crypto-js": "^4.2.0", "event-target-polyfill": "^0.0.4", "flag-icons": "^6.6.6", "flexbin": "^0.2.0", @@ -90,6 +91,7 @@ "@graphql-codegen/typescript-operations": "^4.0.1", "@graphql-codegen/typescript-react-apollo": "^4.1.0", "@types/apollo-upload-client": "^18.0.0", + "@types/crypto-js": "^4.2.2", "@types/lodash-es": "^4.17.6", "@types/mousetrap": "^1.6.11", "@types/node": "^18.13.0", diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 4c94d433e..23e20ea33 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -17,7 +17,8 @@ import "./live"; import "./PlaylistButtons"; import "./source-selector"; import "./persist-volume"; -import "./markers"; +import MarkersPlugin, { type IMarker } from "./markers"; +void MarkersPlugin; import "./vtt-thumbnails"; import "./big-buttons"; import "./track-activity"; @@ -696,21 +697,74 @@ export const ScenePlayer: React.FC = ({ const player = getPlayer(); if (!player) return; - const markers = player.markers(); - markers.clearMarkers(); - for (const marker of scene.scene_markers) { - markers.addMarker({ - title: getMarkerTitle(marker), - time: marker.seconds, - }); - } + function loadMarkers() { + const loadMarkersAsync = async () => { + const markerData = scene.scene_markers.map((marker) => ({ + title: getMarkerTitle(marker), + seconds: marker.seconds, + end_seconds: marker.end_seconds ?? null, + primaryTag: marker.primary_tag, + })); - if (scene.paths.screenshot) { - player.poster(scene.paths.screenshot); - } else { - player.poster(""); + const markers = player!.markers(); + markers.clearMarkers(); + + const uniqueTagNames = markerData + .map((marker) => marker.primaryTag.name) + .filter((value, index, self) => self.indexOf(value) === index); + + // Wait for colors + await markers.findColors(uniqueTagNames); + + const showRangeTags = + !ScreenUtils.isMobile() && (uiConfig?.showRangeMarkers ?? true); + const timestampMarkers: IMarker[] = []; + const rangeMarkers: IMarker[] = []; + + if (!showRangeTags) { + for (const marker of markerData) { + timestampMarkers.push(marker); + } + } else { + for (const marker of markerData) { + if (marker.end_seconds === null) { + timestampMarkers.push(marker); + } else { + rangeMarkers.push(marker); + } + } + } + + // Add markers in chunks + const CHUNK_SIZE = 10; + for (let i = 0; i < timestampMarkers.length; i += CHUNK_SIZE) { + const chunk = timestampMarkers.slice(i, i + CHUNK_SIZE); + requestAnimationFrame(() => { + chunk.forEach((m) => markers.addDotMarker(m)); + }); + } + + requestAnimationFrame(() => { + markers.addRangeMarkers(rangeMarkers); + }); + }; + + // Call our async function + void loadMarkersAsync(); } - }, [getPlayer, scene]); + // Ensure markers are added after player is fully ready and sources are loaded + if (player.readyState() >= 1) { + loadMarkers(); + return; + } else { + player.on("loadedmetadata", () => { + loadMarkers(); + }); + return () => { + player.off("loadedmetadata", loadMarkers); + }; + } + }, [getPlayer, scene, uiConfig]); useEffect(() => { const player = getPlayer(); diff --git a/ui/v2.5/src/components/ScenePlayer/markers.css b/ui/v2.5/src/components/ScenePlayer/markers.css new file mode 100644 index 000000000..398fc90c6 --- /dev/null +++ b/ui/v2.5/src/components/ScenePlayer/markers.css @@ -0,0 +1,27 @@ +.vjs-marker-dot { + position: absolute; + background-color: #10b981; + width: 8px; + height: 8px; + border-radius: 50%; + cursor: pointer; + z-index: 2; + transform: translate(-50%, -50%); + top: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: transform 0.2s ease; +} + +.vjs-marker-dot:hover { + transform: translate(-50%, -50%) scale(1.2); +} + +.vjs-marker-range { + position: absolute; + background-color: rgba(255, 255, 255, 0.4); + height: 8px; + border-radius: 2px; + transform: translateY(-28px); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transition: none; +} diff --git a/ui/v2.5/src/components/ScenePlayer/markers.ts b/ui/v2.5/src/components/ScenePlayer/markers.ts index 97eb0ff31..2b232d686 100644 --- a/ui/v2.5/src/components/ScenePlayer/markers.ts +++ b/ui/v2.5/src/components/ScenePlayer/markers.ts @@ -1,8 +1,11 @@ import videojs, { VideoJsPlayer } from "video.js"; +import "./markers.css"; +import CryptoJS from "crypto-js"; -interface IMarker { +export interface IMarker { title: string; - time: number; + seconds: number; + end_seconds?: number | null; } interface IMarkersOptions { @@ -11,15 +14,21 @@ interface IMarkersOptions { class MarkersPlugin extends videojs.getPlugin("plugin") { private markers: IMarker[] = []; - private markerDivs: HTMLDivElement[] = []; + private markerDivs: { + dot?: HTMLDivElement; + range?: HTMLDivElement; + containedRanges?: HTMLDivElement[]; + }[] = []; private markerTooltip: HTMLElement | null = null; private defaultTooltip: HTMLElement | null = null; - constructor(player: VideoJsPlayer, options?: IMarkersOptions) { - super(player); + private layerHeight: number = 9; + private tagColors: { [tag: string]: string } = {}; + + constructor(player: VideoJsPlayer) { + super(player); player.ready(() => { - // create marker tooltip const tooltip = videojs.dom.createEl("div") as HTMLElement; tooltip.className = "vjs-marker-tooltip"; tooltip.style.visibility = "hidden"; @@ -30,89 +39,200 @@ class MarkersPlugin extends videojs.getPlugin("plugin") { if (parent) parent.appendChild(tooltip); this.markerTooltip = tooltip; - // save default tooltip this.defaultTooltip = player .el() .querySelector( ".vjs-progress-holder .vjs-mouse-display .vjs-time-tooltip" ); - - options?.markers?.forEach(this.addMarker, this); - }); - - player.on("loadedmetadata", () => { - const seekBar = player.el().querySelector(".vjs-progress-holder"); - const duration = this.player.duration(); - - for (let i = 0; i < this.markers.length; i++) { - const marker = this.markers[i]; - const markerDiv = this.markerDivs[i]; - - if (duration) { - // marker is 6px wide - adjust by 3px to align to center not left side - markerDiv.style.left = `calc(${ - (marker.time / duration) * 100 - }% - 3px)`; - markerDiv.style.visibility = "visible"; - } - if (seekBar) seekBar.appendChild(markerDiv); - } }); } - private showMarkerTooltip(title: string) { + 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"; - - // hide default tooltip if (this.defaultTooltip) this.defaultTooltip.style.visibility = "hidden"; } private hideMarkerTooltip() { if (this.markerTooltip) this.markerTooltip.style.visibility = "hidden"; - - // show default tooltip if (this.defaultTooltip) this.defaultTooltip.style.visibility = "visible"; } - addMarker(marker: IMarker) { - const markerDiv = videojs.dom.createEl("div") as HTMLDivElement; - markerDiv.className = "vjs-marker"; - + 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) { - // marker is 6px wide - adjust by 3px to align to center not left side - markerDiv.style.left = `calc(${(marker.time / duration) * 100}% - 3px)`; - markerDiv.style.visibility = "visible"; + markerSet.dot.style.left = `calc(${ + (marker.seconds / duration) * 100 + }% - 3px)`; } - // bind click event to seek to marker time - markerDiv.addEventListener("click", () => - this.player.currentTime(marker.time) + // Add event listeners to dot + markerSet.dot.addEventListener("click", () => + this.player.currentTime(marker.seconds) ); + markerSet.dot.toggleAttribute("marker-tooltip-shown", true); - // show/hide tooltip on hover - markerDiv.addEventListener("mouseenter", () => { + // 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); - markerDiv.toggleAttribute("marker-tooltip-shown", true); + markerSet.dot?.toggleAttribute("marker-tooltip-shown", true); }); - markerDiv.addEventListener("mouseout", () => { + + markerSet.dot.addEventListener("mouseout", () => { this.hideMarkerTooltip(); - markerDiv.toggleAttribute("marker-tooltip-shown", false); + markerSet.dot?.toggleAttribute("marker-tooltip-shown", false); }); - const seekBar = this.player.el().querySelector(".vjs-progress-holder"); - if (seekBar) seekBar.appendChild(markerDiv); - - this.markers.push(marker); - this.markerDivs.push(markerDiv); + if (seekBar) { + seekBar.appendChild(markerSet.dot); + } } - addMarkers(markers: IMarker[]) { - markers.forEach(this.addMarker, this); + 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) { @@ -120,12 +240,14 @@ class MarkersPlugin extends videojs.getPlugin("plugin") { if (i === -1) return; this.markers.splice(i, 1); + const markerSet = this.markerDivs.splice(i, 1)[0]; - const div = this.markerDivs.splice(i, 1)[0]; - if (div.hasAttribute("marker-tooltip-shown")) { + if (markerSet.dot?.hasAttribute("marker-tooltip-shown")) { this.hideMarkerTooltip(); } - div.remove(); + + markerSet.dot?.remove(); + if (markerSet.range) markerSet.range.remove(); } removeMarkers(markers: IMarker[]) { @@ -135,9 +257,177 @@ class MarkersPlugin extends videojs.getPlugin("plugin") { 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"); + } } -// Register the plugin with video.js. videojs.registerPlugin("markers", MarkersPlugin); /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index b3ed8445e..bb3b4b9a5 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -183,6 +183,7 @@ $sceneTabWidth: 450px; pointer-events: none; position: absolute; transition: opacity 0.2s; + z-index: 100; } .vjs-big-play-button, diff --git a/ui/v2.5/src/components/ScenePlayer/vtt-thumbnails.ts b/ui/v2.5/src/components/ScenePlayer/vtt-thumbnails.ts index 33495eec7..a9c8f9d9e 100644 --- a/ui/v2.5/src/components/ScenePlayer/vtt-thumbnails.ts +++ b/ui/v2.5/src/components/ScenePlayer/vtt-thumbnails.ts @@ -151,8 +151,8 @@ class VTTThumbnailsPlugin extends videojs.getPlugin("plugin") { this.player.$(".vjs-mouse-display")?.classList.add("vjs-hidden"); } - progressBar.addEventListener("pointerenter", this.onBarPointerEnter); - progressBar.addEventListener("pointerleave", this.onBarPointerLeave); + progressBar.addEventListener("pointerover", this.onBarPointerEnter); + progressBar.addEventListener("pointerout", this.onBarPointerLeave); } private onBarPointerEnter = () => { diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index f041decd3..1802fefe7 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -357,6 +357,12 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( checked={iface.showScrubber ?? undefined} onChange={(v) => saveInterface({ showScrubber: v })} /> + saveUI({ showRangeMarkers: v })} + />