mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
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:
@@ -39,6 +39,7 @@
|
|||||||
"base64-blob": "^1.4.1",
|
"base64-blob": "^1.4.1",
|
||||||
"bootstrap": "^4.6.2",
|
"bootstrap": "^4.6.2",
|
||||||
"classnames": "^2.3.2",
|
"classnames": "^2.3.2",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
"event-target-polyfill": "^0.0.4",
|
"event-target-polyfill": "^0.0.4",
|
||||||
"flag-icons": "^6.6.6",
|
"flag-icons": "^6.6.6",
|
||||||
"flexbin": "^0.2.0",
|
"flexbin": "^0.2.0",
|
||||||
@@ -90,6 +91,7 @@
|
|||||||
"@graphql-codegen/typescript-operations": "^4.0.1",
|
"@graphql-codegen/typescript-operations": "^4.0.1",
|
||||||
"@graphql-codegen/typescript-react-apollo": "^4.1.0",
|
"@graphql-codegen/typescript-react-apollo": "^4.1.0",
|
||||||
"@types/apollo-upload-client": "^18.0.0",
|
"@types/apollo-upload-client": "^18.0.0",
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/lodash-es": "^4.17.6",
|
"@types/lodash-es": "^4.17.6",
|
||||||
"@types/mousetrap": "^1.6.11",
|
"@types/mousetrap": "^1.6.11",
|
||||||
"@types/node": "^18.13.0",
|
"@types/node": "^18.13.0",
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import "./live";
|
|||||||
import "./PlaylistButtons";
|
import "./PlaylistButtons";
|
||||||
import "./source-selector";
|
import "./source-selector";
|
||||||
import "./persist-volume";
|
import "./persist-volume";
|
||||||
import "./markers";
|
import MarkersPlugin, { type IMarker } from "./markers";
|
||||||
|
void MarkersPlugin;
|
||||||
import "./vtt-thumbnails";
|
import "./vtt-thumbnails";
|
||||||
import "./big-buttons";
|
import "./big-buttons";
|
||||||
import "./track-activity";
|
import "./track-activity";
|
||||||
@@ -696,21 +697,74 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
|||||||
const player = getPlayer();
|
const player = getPlayer();
|
||||||
if (!player) return;
|
if (!player) return;
|
||||||
|
|
||||||
const markers = player.markers();
|
function loadMarkers() {
|
||||||
markers.clearMarkers();
|
const loadMarkersAsync = async () => {
|
||||||
for (const marker of scene.scene_markers) {
|
const markerData = scene.scene_markers.map((marker) => ({
|
||||||
markers.addMarker({
|
|
||||||
title: getMarkerTitle(marker),
|
title: getMarkerTitle(marker),
|
||||||
time: marker.seconds,
|
seconds: marker.seconds,
|
||||||
|
end_seconds: marker.end_seconds ?? null,
|
||||||
|
primaryTag: marker.primary_tag,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scene.paths.screenshot) {
|
requestAnimationFrame(() => {
|
||||||
player.poster(scene.paths.screenshot);
|
markers.addRangeMarkers(rangeMarkers);
|
||||||
} else {
|
});
|
||||||
player.poster("");
|
};
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
const player = getPlayer();
|
const player = getPlayer();
|
||||||
|
|||||||
27
ui/v2.5/src/components/ScenePlayer/markers.css
Normal file
27
ui/v2.5/src/components/ScenePlayer/markers.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import videojs, { VideoJsPlayer } from "video.js";
|
import videojs, { VideoJsPlayer } from "video.js";
|
||||||
|
import "./markers.css";
|
||||||
|
import CryptoJS from "crypto-js";
|
||||||
|
|
||||||
interface IMarker {
|
export interface IMarker {
|
||||||
title: string;
|
title: string;
|
||||||
time: number;
|
seconds: number;
|
||||||
|
end_seconds?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IMarkersOptions {
|
interface IMarkersOptions {
|
||||||
@@ -11,15 +14,21 @@ interface IMarkersOptions {
|
|||||||
|
|
||||||
class MarkersPlugin extends videojs.getPlugin("plugin") {
|
class MarkersPlugin extends videojs.getPlugin("plugin") {
|
||||||
private markers: IMarker[] = [];
|
private markers: IMarker[] = [];
|
||||||
private markerDivs: HTMLDivElement[] = [];
|
private markerDivs: {
|
||||||
|
dot?: HTMLDivElement;
|
||||||
|
range?: HTMLDivElement;
|
||||||
|
containedRanges?: HTMLDivElement[];
|
||||||
|
}[] = [];
|
||||||
private markerTooltip: HTMLElement | null = null;
|
private markerTooltip: HTMLElement | null = null;
|
||||||
private defaultTooltip: HTMLElement | null = null;
|
private defaultTooltip: HTMLElement | null = null;
|
||||||
|
|
||||||
constructor(player: VideoJsPlayer, options?: IMarkersOptions) {
|
private layerHeight: number = 9;
|
||||||
super(player);
|
|
||||||
|
|
||||||
|
private tagColors: { [tag: string]: string } = {};
|
||||||
|
|
||||||
|
constructor(player: VideoJsPlayer) {
|
||||||
|
super(player);
|
||||||
player.ready(() => {
|
player.ready(() => {
|
||||||
// create marker tooltip
|
|
||||||
const tooltip = videojs.dom.createEl("div") as HTMLElement;
|
const tooltip = videojs.dom.createEl("div") as HTMLElement;
|
||||||
tooltip.className = "vjs-marker-tooltip";
|
tooltip.className = "vjs-marker-tooltip";
|
||||||
tooltip.style.visibility = "hidden";
|
tooltip.style.visibility = "hidden";
|
||||||
@@ -30,89 +39,200 @@ class MarkersPlugin extends videojs.getPlugin("plugin") {
|
|||||||
if (parent) parent.appendChild(tooltip);
|
if (parent) parent.appendChild(tooltip);
|
||||||
this.markerTooltip = tooltip;
|
this.markerTooltip = tooltip;
|
||||||
|
|
||||||
// save default tooltip
|
|
||||||
this.defaultTooltip = player
|
this.defaultTooltip = player
|
||||||
.el()
|
.el()
|
||||||
.querySelector<HTMLElement>(
|
.querySelector<HTMLElement>(
|
||||||
".vjs-progress-holder .vjs-mouse-display .vjs-time-tooltip"
|
".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;
|
if (!this.markerTooltip) return;
|
||||||
|
|
||||||
this.markerTooltip.innerText = title;
|
this.markerTooltip.innerText = title;
|
||||||
this.markerTooltip.style.right = `${-this.markerTooltip.clientWidth / 2}px`;
|
this.markerTooltip.style.right = `${-this.markerTooltip.clientWidth / 2}px`;
|
||||||
|
this.markerTooltip.style.top = `-${this.layerHeight * layer + 50}px`;
|
||||||
this.markerTooltip.style.visibility = "visible";
|
this.markerTooltip.style.visibility = "visible";
|
||||||
|
|
||||||
// hide default tooltip
|
|
||||||
if (this.defaultTooltip) this.defaultTooltip.style.visibility = "hidden";
|
if (this.defaultTooltip) this.defaultTooltip.style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
private hideMarkerTooltip() {
|
private hideMarkerTooltip() {
|
||||||
if (this.markerTooltip) this.markerTooltip.style.visibility = "hidden";
|
if (this.markerTooltip) this.markerTooltip.style.visibility = "hidden";
|
||||||
|
|
||||||
// show default tooltip
|
|
||||||
if (this.defaultTooltip) this.defaultTooltip.style.visibility = "visible";
|
if (this.defaultTooltip) this.defaultTooltip.style.visibility = "visible";
|
||||||
}
|
}
|
||||||
|
|
||||||
addMarker(marker: IMarker) {
|
addDotMarker(marker: IMarker) {
|
||||||
const markerDiv = videojs.dom.createEl("div") as HTMLDivElement;
|
|
||||||
markerDiv.className = "vjs-marker";
|
|
||||||
|
|
||||||
const duration = this.player.duration();
|
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) {
|
if (duration) {
|
||||||
// marker is 6px wide - adjust by 3px to align to center not left side
|
markerSet.dot.style.left = `calc(${
|
||||||
markerDiv.style.left = `calc(${(marker.time / duration) * 100}% - 3px)`;
|
(marker.seconds / duration) * 100
|
||||||
markerDiv.style.visibility = "visible";
|
}% - 3px)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// bind click event to seek to marker time
|
// Add event listeners to dot
|
||||||
markerDiv.addEventListener("click", () =>
|
markerSet.dot.addEventListener("click", () =>
|
||||||
this.player.currentTime(marker.time)
|
this.player.currentTime(marker.seconds)
|
||||||
);
|
);
|
||||||
|
markerSet.dot.toggleAttribute("marker-tooltip-shown", true);
|
||||||
|
|
||||||
// show/hide tooltip on hover
|
// Set background color based on tag (if available)
|
||||||
markerDiv.addEventListener("mouseenter", () => {
|
if (marker.title && this.tagColors[marker.title]) {
|
||||||
|
markerSet.dot.style.backgroundColor = this.tagColors[marker.title];
|
||||||
|
}
|
||||||
|
markerSet.dot.addEventListener("mouseenter", () => {
|
||||||
this.showMarkerTooltip(marker.title);
|
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();
|
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) {
|
||||||
if (seekBar) seekBar.appendChild(markerDiv);
|
seekBar.appendChild(markerSet.dot);
|
||||||
|
}
|
||||||
this.markers.push(marker);
|
|
||||||
this.markerDivs.push(markerDiv);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addMarkers(markers: IMarker[]) {
|
private renderRangeMarkers(markers: IMarker[], layer: number) {
|
||||||
markers.forEach(this.addMarker, this);
|
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) {
|
removeMarker(marker: IMarker) {
|
||||||
@@ -120,12 +240,14 @@ class MarkersPlugin extends videojs.getPlugin("plugin") {
|
|||||||
if (i === -1) return;
|
if (i === -1) return;
|
||||||
|
|
||||||
this.markers.splice(i, 1);
|
this.markers.splice(i, 1);
|
||||||
|
const markerSet = this.markerDivs.splice(i, 1)[0];
|
||||||
|
|
||||||
const div = this.markerDivs.splice(i, 1)[0];
|
if (markerSet.dot?.hasAttribute("marker-tooltip-shown")) {
|
||||||
if (div.hasAttribute("marker-tooltip-shown")) {
|
|
||||||
this.hideMarkerTooltip();
|
this.hideMarkerTooltip();
|
||||||
}
|
}
|
||||||
div.remove();
|
|
||||||
|
markerSet.dot?.remove();
|
||||||
|
if (markerSet.range) markerSet.range.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
removeMarkers(markers: IMarker[]) {
|
removeMarkers(markers: IMarker[]) {
|
||||||
@@ -135,9 +257,177 @@ class MarkersPlugin extends videojs.getPlugin("plugin") {
|
|||||||
clearMarkers() {
|
clearMarkers() {
|
||||||
this.removeMarkers([...this.markers]);
|
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);
|
videojs.registerPlugin("markers", MarkersPlugin);
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/naming-convention */
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ $sceneTabWidth: 450px;
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vjs-big-play-button,
|
.vjs-big-play-button,
|
||||||
|
|||||||
@@ -151,8 +151,8 @@ class VTTThumbnailsPlugin extends videojs.getPlugin("plugin") {
|
|||||||
this.player.$(".vjs-mouse-display")?.classList.add("vjs-hidden");
|
this.player.$(".vjs-mouse-display")?.classList.add("vjs-hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
progressBar.addEventListener("pointerenter", this.onBarPointerEnter);
|
progressBar.addEventListener("pointerover", this.onBarPointerEnter);
|
||||||
progressBar.addEventListener("pointerleave", this.onBarPointerLeave);
|
progressBar.addEventListener("pointerout", this.onBarPointerLeave);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onBarPointerEnter = () => {
|
private onBarPointerEnter = () => {
|
||||||
|
|||||||
@@ -357,6 +357,12 @@ export const SettingsInterfacePanel: React.FC = PatchComponent(
|
|||||||
checked={iface.showScrubber ?? undefined}
|
checked={iface.showScrubber ?? undefined}
|
||||||
onChange={(v) => saveInterface({ showScrubber: v })}
|
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
|
<BooleanSetting
|
||||||
id="always-start-from-beginning"
|
id="always-start-from-beginning"
|
||||||
headingID="config.ui.scene_player.options.always_start_from_beginning"
|
headingID="config.ui.scene_player.options.always_start_from_beginning"
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export interface IUIConfig {
|
|||||||
// if true the fullscreen mobile media auto-rotate option will be disabled
|
// if true the fullscreen mobile media auto-rotate option will be disabled
|
||||||
disableMobileMediaAutoRotateEnabled?: boolean;
|
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
|
// if true continue scene will always play from the beginning
|
||||||
alwaysStartFromBeginning?: boolean;
|
alwaysStartFromBeginning?: boolean;
|
||||||
// if true enable activity tracking
|
// if true enable activity tracking
|
||||||
|
|||||||
@@ -750,6 +750,7 @@
|
|||||||
"enable_chromecast": "Enable Chromecast",
|
"enable_chromecast": "Enable Chromecast",
|
||||||
"show_ab_loop_controls": "Show AB Loop plugin controls",
|
"show_ab_loop_controls": "Show AB Loop plugin controls",
|
||||||
"show_scrubber": "Show Scrubber",
|
"show_scrubber": "Show Scrubber",
|
||||||
|
"show_range_markers": "Show Range Markers",
|
||||||
"track_activity": "Enable Scene Play history",
|
"track_activity": "Enable Scene Play history",
|
||||||
"vr_tag": {
|
"vr_tag": {
|
||||||
"description": "The VR button will only be displayed for scenes with this tag.",
|
"description": "The VR button will only be displayed for scenes with this tag.",
|
||||||
|
|||||||
@@ -2176,6 +2176,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803"
|
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803"
|
||||||
integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==
|
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@*":
|
"@types/extract-files@*":
|
||||||
version "13.0.1"
|
version "13.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/extract-files/-/extract-files-13.0.1.tgz#3ec057a3fa25f778245a76a17271d23b71ee31d7"
|
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"
|
shebang-command "^2.0.0"
|
||||||
which "^2.0.1"
|
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:
|
css-functions-list@^3.1.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/css-functions-list/-/css-functions-list-3.1.0.tgz#cf5b09f835ad91a00e5959bcfc627cd498e1321b"
|
resolved "https://registry.yarnpkg.com/css-functions-list/-/css-functions-list-3.1.0.tgz#cf5b09f835ad91a00e5959bcfc627cd498e1321b"
|
||||||
|
|||||||
Reference in New Issue
Block a user