Add UI for Markers with end seconds on scene player. (#5633)

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
skier233
2025-02-18 01:10:15 -05:00
committed by GitHub
parent c8032f04fa
commit 3ea49c6c2e
10 changed files with 469 additions and 76 deletions

View File

@@ -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",

View File

@@ -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<IScenePlayerProps> = ({
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();

View File

@@ -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;
}

View File

@@ -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<HTMLElement>(
".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<number> {
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 */

View File

@@ -183,6 +183,7 @@ $sceneTabWidth: 450px;
pointer-events: none;
position: absolute;
transition: opacity 0.2s;
z-index: 100;
}
.vjs-big-play-button,

View File

@@ -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 = () => {

View File

@@ -357,6 +357,12 @@ export const SettingsInterfacePanel: React.FC = PatchComponent(
checked={iface.showScrubber ?? undefined}
onChange={(v) => saveInterface({ showScrubber: v })}
/>
<BooleanSetting
id="show-range-markers"
headingID="config.ui.scene_player.options.show_range_markers"
checked={ui.showRangeMarkers ?? undefined}
onChange={(v) => saveUI({ showRangeMarkers: v })}
/>
<BooleanSetting
id="always-start-from-beginning"
headingID="config.ui.scene_player.options.always_start_from_beginning"

View File

@@ -70,6 +70,8 @@ export interface IUIConfig {
// if true the fullscreen mobile media auto-rotate option will be disabled
disableMobileMediaAutoRotateEnabled?: boolean;
// if true markers with end times will display with a horizontal bar in the scene player
showRangeMarkers?: boolean;
// if true continue scene will always play from the beginning
alwaysStartFromBeginning?: boolean;
// if true enable activity tracking

View File

@@ -750,6 +750,7 @@
"enable_chromecast": "Enable Chromecast",
"show_ab_loop_controls": "Show AB Loop plugin controls",
"show_scrubber": "Show Scrubber",
"show_range_markers": "Show Range Markers",
"track_activity": "Enable Scene Play history",
"vr_tag": {
"description": "The VR button will only be displayed for scenes with this tag.",

View File

@@ -2176,6 +2176,11 @@
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803"
integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==
"@types/crypto-js@^4.2.2":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@types/crypto-js/-/crypto-js-4.2.2.tgz#771c4a768d94eb5922cc202a3009558204df0cea"
integrity sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==
"@types/extract-files@*":
version "13.0.1"
resolved "https://registry.yarnpkg.com/@types/extract-files/-/extract-files-13.0.1.tgz#3ec057a3fa25f778245a76a17271d23b71ee31d7"
@@ -3506,6 +3511,11 @@ cross-spawn@^7.0.2:
shebang-command "^2.0.0"
which "^2.0.1"
crypto-js@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631"
integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==
css-functions-list@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/css-functions-list/-/css-functions-list-3.1.0.tgz#cf5b09f835ad91a00e5959bcfc627cd498e1321b"