mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44:37 +03:00
912 lines
23 KiB
TypeScript
912 lines
23 KiB
TypeScript
import React, {
|
|
KeyboardEvent,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from "video.js";
|
|
import useScript from "src/hooks/useScript";
|
|
import "videojs-contrib-dash";
|
|
import "videojs-mobile-ui";
|
|
import "videojs-seek-buttons";
|
|
import { UAParser } from "ua-parser-js";
|
|
import "./live";
|
|
import "./PlaylistButtons";
|
|
import "./source-selector";
|
|
import "./persist-volume";
|
|
import MarkersPlugin, { type IMarker } from "./markers";
|
|
void MarkersPlugin;
|
|
import "./vtt-thumbnails";
|
|
import "./big-buttons";
|
|
import "./track-activity";
|
|
import "./vrmode";
|
|
import cx from "classnames";
|
|
import {
|
|
useSceneSaveActivity,
|
|
useSceneIncrementPlayCount,
|
|
} from "src/core/StashService";
|
|
|
|
import * as GQL from "src/core/generated-graphql";
|
|
import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
|
|
import { ConfigurationContext } from "src/hooks/Config";
|
|
import {
|
|
ConnectionState,
|
|
InteractiveContext,
|
|
} from "src/hooks/Interactive/context";
|
|
import { SceneInteractiveStatus } from "src/hooks/Interactive/status";
|
|
import { languageMap } from "src/utils/caption";
|
|
import { VIDEO_PLAYER_ID } from "./util";
|
|
|
|
// @ts-ignore
|
|
import airplay from "@silvermine/videojs-airplay";
|
|
// @ts-ignore
|
|
import chromecast from "@silvermine/videojs-chromecast";
|
|
import abLoopPlugin from "videojs-abloop";
|
|
import ScreenUtils from "src/utils/screen";
|
|
|
|
// register videojs plugins
|
|
airplay(videojs);
|
|
chromecast(videojs);
|
|
abLoopPlugin(window, videojs);
|
|
|
|
function handleHotkeys(player: VideoJsPlayer, event: videojs.KeyboardEvent) {
|
|
function seekStep(step: number) {
|
|
const time = player.currentTime() + step;
|
|
const duration = player.duration();
|
|
if (time < 0) {
|
|
player.currentTime(0);
|
|
} else if (time < duration) {
|
|
player.currentTime(time);
|
|
} else {
|
|
player.currentTime(duration);
|
|
}
|
|
}
|
|
|
|
function seekPercent(percent: number) {
|
|
const duration = player.duration();
|
|
const time = duration * percent;
|
|
player.currentTime(time);
|
|
}
|
|
|
|
function seekPercentRelative(percent: number) {
|
|
const duration = player.duration();
|
|
const currentTime = player.currentTime();
|
|
const time = currentTime + duration * percent;
|
|
if (time > duration) return;
|
|
player.currentTime(time);
|
|
}
|
|
|
|
function toggleABLooping() {
|
|
const opts = player.abLoopPlugin.getOptions();
|
|
if (!opts.start) {
|
|
opts.start = player.currentTime();
|
|
} else if (!opts.end) {
|
|
opts.end = player.currentTime();
|
|
opts.enabled = true;
|
|
} else {
|
|
opts.start = 0;
|
|
opts.end = 0;
|
|
opts.enabled = false;
|
|
}
|
|
player.abLoopPlugin.setOptions(opts);
|
|
}
|
|
|
|
let seekFactor = 10;
|
|
if (event.shiftKey) {
|
|
seekFactor = 5;
|
|
} else if (event.ctrlKey || event.altKey) {
|
|
seekFactor = 60;
|
|
}
|
|
switch (event.which) {
|
|
case 39: // right arrow
|
|
seekStep(seekFactor);
|
|
break;
|
|
case 37: // left arrow
|
|
seekStep(-seekFactor);
|
|
break;
|
|
}
|
|
|
|
// toggle player looping with shift+l
|
|
if (event.shiftKey && event.which === 76) {
|
|
player.loop(!player.loop());
|
|
return;
|
|
}
|
|
|
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
|
|
return;
|
|
}
|
|
|
|
switch (event.which) {
|
|
case 32: // space
|
|
case 13: // enter
|
|
if (player.paused()) player.play();
|
|
else player.pause();
|
|
break;
|
|
case 77: // m
|
|
player.muted(!player.muted());
|
|
break;
|
|
case 70: // f
|
|
if (player.isFullscreen()) player.exitFullscreen();
|
|
else player.requestFullscreen();
|
|
break;
|
|
case 76: // l
|
|
toggleABLooping();
|
|
break;
|
|
case 38: // up arrow
|
|
player.volume(player.volume() + 0.1);
|
|
break;
|
|
case 40: // down arrow
|
|
player.volume(player.volume() - 0.1);
|
|
break;
|
|
case 48: // 0
|
|
player.currentTime(0);
|
|
break;
|
|
case 49: // 1
|
|
seekPercent(0.1);
|
|
break;
|
|
case 50: // 2
|
|
seekPercent(0.2);
|
|
break;
|
|
case 51: // 3
|
|
seekPercent(0.3);
|
|
break;
|
|
case 52: // 4
|
|
seekPercent(0.4);
|
|
break;
|
|
case 53: // 5
|
|
seekPercent(0.5);
|
|
break;
|
|
case 54: // 6
|
|
seekPercent(0.6);
|
|
break;
|
|
case 55: // 7
|
|
seekPercent(0.7);
|
|
break;
|
|
case 56: // 8
|
|
seekPercent(0.8);
|
|
break;
|
|
case 57: // 9
|
|
seekPercent(0.9);
|
|
break;
|
|
case 221: // ]
|
|
seekPercentRelative(0.1);
|
|
break;
|
|
case 219: // [
|
|
seekPercentRelative(-0.1);
|
|
break;
|
|
}
|
|
}
|
|
|
|
type MarkerFragment = Pick<GQL.SceneMarker, "title" | "seconds"> & {
|
|
primary_tag: Pick<GQL.Tag, "name">;
|
|
tags: Array<Pick<GQL.Tag, "name">>;
|
|
};
|
|
|
|
function getMarkerTitle(marker: MarkerFragment) {
|
|
if (marker.title) {
|
|
return marker.title;
|
|
}
|
|
|
|
let ret = marker.primary_tag.name;
|
|
if (marker.tags.length) {
|
|
ret += `, ${marker.tags.map((t) => t.name).join(", ")}`;
|
|
}
|
|
|
|
return ret;
|
|
}
|
|
|
|
interface IScenePlayerProps {
|
|
scene: GQL.SceneDataFragment;
|
|
hideScrubberOverride: boolean;
|
|
autoplay?: boolean;
|
|
permitLoop?: boolean;
|
|
initialTimestamp: number;
|
|
sendSetTimestamp: (setTimestamp: (value: number) => void) => void;
|
|
onComplete: () => void;
|
|
onNext: () => void;
|
|
onPrevious: () => void;
|
|
}
|
|
|
|
export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
|
scene,
|
|
hideScrubberOverride,
|
|
autoplay,
|
|
permitLoop = true,
|
|
initialTimestamp: _initialTimestamp,
|
|
sendSetTimestamp,
|
|
onComplete,
|
|
onNext,
|
|
onPrevious,
|
|
}) => {
|
|
const { configuration } = useContext(ConfigurationContext);
|
|
const interfaceConfig = configuration?.interface;
|
|
const uiConfig = configuration?.ui;
|
|
const videoRef = useRef<HTMLDivElement>(null);
|
|
const [_player, setPlayer] = useState<VideoJsPlayer>();
|
|
const sceneId = useRef<string>();
|
|
const [sceneSaveActivity] = useSceneSaveActivity();
|
|
const [sceneIncrementPlayCount] = useSceneIncrementPlayCount();
|
|
|
|
const [time, setTime] = useState(0);
|
|
const [ready, setReady] = useState(false);
|
|
|
|
const {
|
|
interactive: interactiveClient,
|
|
uploadScript,
|
|
currentScript,
|
|
initialised: interactiveInitialised,
|
|
state: interactiveState,
|
|
} = React.useContext(InteractiveContext);
|
|
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
const [showScrubber, setShowScrubber] = useState(false);
|
|
|
|
const started = useRef(false);
|
|
const auto = useRef(false);
|
|
const interactiveReady = useRef(false);
|
|
const minimumPlayPercent = uiConfig?.minimumPlayPercent ?? 0;
|
|
const trackActivity = uiConfig?.trackActivity ?? true;
|
|
const vrTag = uiConfig?.vrTag ?? undefined;
|
|
|
|
useScript(
|
|
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1",
|
|
uiConfig?.enableChromecast
|
|
);
|
|
|
|
const file = useMemo(
|
|
() => (scene.files.length > 0 ? scene.files[0] : undefined),
|
|
[scene]
|
|
);
|
|
|
|
const maxLoopDuration = interfaceConfig?.maximumLoopDuration ?? 0;
|
|
const looping = useMemo(
|
|
() =>
|
|
!!file?.duration &&
|
|
permitLoop &&
|
|
maxLoopDuration !== 0 &&
|
|
file.duration < maxLoopDuration,
|
|
[file, permitLoop, maxLoopDuration]
|
|
);
|
|
|
|
const getPlayer = useCallback(() => {
|
|
if (!_player) return null;
|
|
if (_player.isDisposed()) return null;
|
|
return _player;
|
|
}, [_player]);
|
|
|
|
useEffect(() => {
|
|
if (hideScrubberOverride || fullscreen) {
|
|
setShowScrubber(false);
|
|
return;
|
|
}
|
|
|
|
const onResize = () => {
|
|
const show = window.innerHeight >= 450 && !ScreenUtils.isMobile();
|
|
setShowScrubber(show);
|
|
};
|
|
onResize();
|
|
|
|
window.addEventListener("resize", onResize);
|
|
|
|
return () => window.removeEventListener("resize", onResize);
|
|
}, [hideScrubberOverride, fullscreen]);
|
|
|
|
useEffect(() => {
|
|
sendSetTimestamp((value: number) => {
|
|
const player = getPlayer();
|
|
if (player && value >= 0) {
|
|
if (player.hasStarted() && player.paused()) {
|
|
player.currentTime(value);
|
|
} else {
|
|
player.play()?.then(() => {
|
|
player.currentTime(value);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}, [sendSetTimestamp, getPlayer]);
|
|
|
|
// Initialize VideoJS player
|
|
useEffect(() => {
|
|
const options: VideoJsPlayerOptions = {
|
|
id: VIDEO_PLAYER_ID,
|
|
controls: true,
|
|
controlBar: {
|
|
pictureInPictureToggle: false,
|
|
volumePanel: {
|
|
inline: false,
|
|
},
|
|
chaptersButton: false,
|
|
},
|
|
html5: {
|
|
dash: {
|
|
updateSettings: [
|
|
{
|
|
streaming: {
|
|
buffer: {
|
|
bufferTimeAtTopQuality: 30,
|
|
bufferTimeAtTopQualityLongForm: 30,
|
|
},
|
|
gaps: {
|
|
jumpGaps: false,
|
|
jumpLargeGaps: false,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
nativeControlsForTouch: false,
|
|
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
|
|
inactivityTimeout: 2000,
|
|
preload: "none",
|
|
playsinline: true,
|
|
techOrder: ["chromecast", "html5"],
|
|
userActions: {
|
|
hotkeys: function (this: VideoJsPlayer, event) {
|
|
handleHotkeys(this, event);
|
|
},
|
|
},
|
|
plugins: {
|
|
airPlay: {},
|
|
chromecast: {},
|
|
vttThumbnails: {
|
|
showTimestamp: true,
|
|
},
|
|
markers: {},
|
|
sourceSelector: {},
|
|
persistVolume: {},
|
|
bigButtons: {},
|
|
seekButtons: {
|
|
forward: 10,
|
|
back: 10,
|
|
},
|
|
skipButtons: {},
|
|
trackActivity: {},
|
|
vrMenu: {},
|
|
abLoopPlugin: {
|
|
start: 0,
|
|
end: false,
|
|
enabled: false,
|
|
loopIfBeforeStart: true,
|
|
loopIfAfterEnd: true,
|
|
pauseAfterLooping: false,
|
|
pauseBeforeLooping: false,
|
|
createButtons: uiConfig?.showAbLoopControls ?? false,
|
|
},
|
|
},
|
|
};
|
|
|
|
const videoEl = document.createElement("video-js");
|
|
videoEl.setAttribute("data-vjs-player", "true");
|
|
videoEl.setAttribute("crossorigin", "anonymous");
|
|
videoEl.classList.add("vjs-big-play-centered");
|
|
videoRef.current!.appendChild(videoEl);
|
|
|
|
const vjs = videojs(videoEl, options);
|
|
|
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
const settings = (vjs as any).textTrackSettings;
|
|
settings.setValues({
|
|
backgroundColor: "#000",
|
|
backgroundOpacity: "0.5",
|
|
});
|
|
settings.updateDisplay();
|
|
|
|
vjs.focus();
|
|
setPlayer(vjs);
|
|
|
|
// Video player destructor
|
|
return () => {
|
|
vjs.dispose();
|
|
videoEl.remove();
|
|
setPlayer(undefined);
|
|
|
|
// reset sceneId to force reload sources
|
|
sceneId.current = undefined;
|
|
};
|
|
// empty deps - only init once
|
|
// showAbLoopControls is necessary to re-init the player when the config changes
|
|
}, [uiConfig?.showAbLoopControls]);
|
|
|
|
useEffect(() => {
|
|
const player = getPlayer();
|
|
if (!player) return;
|
|
const skipButtons = player.skipButtons();
|
|
skipButtons.setForwardHandler(onNext);
|
|
skipButtons.setBackwardHandler(onPrevious);
|
|
}, [getPlayer, onNext, onPrevious]);
|
|
|
|
useEffect(() => {
|
|
if (scene.interactive && interactiveInitialised) {
|
|
interactiveReady.current = false;
|
|
uploadScript(scene.paths.funscript || "").then(() => {
|
|
interactiveReady.current = true;
|
|
});
|
|
}
|
|
}, [
|
|
uploadScript,
|
|
interactiveInitialised,
|
|
scene.interactive,
|
|
scene.paths.funscript,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const player = getPlayer();
|
|
if (!player) return;
|
|
|
|
const vrMenu = player.vrMenu();
|
|
|
|
let showButton = false;
|
|
|
|
if (vrTag) {
|
|
showButton = scene.tags.some((tag) => vrTag === tag.name);
|
|
}
|
|
|
|
vrMenu.setShowButton(showButton);
|
|
}, [getPlayer, scene, vrTag]);
|
|
|
|
// Player event handlers
|
|
useEffect(() => {
|
|
const player = getPlayer();
|
|
if (!player) return;
|
|
|
|
function canplay(this: VideoJsPlayer) {
|
|
// if we're seeking before starting, don't set the initial timestamp
|
|
// when starting from the beginning, there is a small delay before the event
|
|
// is triggered, so we can't just check if the time is 0
|
|
if (this.currentTime() >= 0.1) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
function playing(this: VideoJsPlayer) {
|
|
// This still runs even if autoplay failed on Safari,
|
|
// only set flag if actually playing
|
|
if (!started.current && !this.paused()) {
|
|
started.current = true;
|
|
}
|
|
}
|
|
|
|
function loadstart(this: VideoJsPlayer) {
|
|
setReady(true);
|
|
}
|
|
|
|
function fullscreenchange(this: VideoJsPlayer) {
|
|
setFullscreen(this.isFullscreen());
|
|
}
|
|
|
|
player.on("canplay", canplay);
|
|
player.on("playing", playing);
|
|
player.on("loadstart", loadstart);
|
|
player.on("fullscreenchange", fullscreenchange);
|
|
|
|
return () => {
|
|
player.off("canplay", canplay);
|
|
player.off("playing", playing);
|
|
player.off("loadstart", loadstart);
|
|
player.off("fullscreenchange", fullscreenchange);
|
|
};
|
|
}, [getPlayer]);
|
|
|
|
useEffect(() => {
|
|
const player = getPlayer();
|
|
if (!player) return;
|
|
|
|
function onplay(this: VideoJsPlayer) {
|
|
if (scene.interactive && interactiveReady.current) {
|
|
interactiveClient.play(this.currentTime());
|
|
}
|
|
}
|
|
|
|
function pause(this: VideoJsPlayer) {
|
|
interactiveClient.pause();
|
|
}
|
|
|
|
function seeking(this: VideoJsPlayer) {
|
|
if (this.paused()) return;
|
|
if (scene.interactive && interactiveReady.current) {
|
|
interactiveClient.play(this.currentTime());
|
|
}
|
|
}
|
|
|
|
function timeupdate(this: VideoJsPlayer) {
|
|
if (this.paused()) return;
|
|
if (scene.interactive && interactiveReady.current) {
|
|
interactiveClient.ensurePlaying(this.currentTime());
|
|
}
|
|
setTime(this.currentTime());
|
|
}
|
|
|
|
player.on("play", onplay);
|
|
player.on("pause", pause);
|
|
player.on("seeking", seeking);
|
|
player.on("timeupdate", timeupdate);
|
|
|
|
return () => {
|
|
player.off("play", onplay);
|
|
player.off("pause", pause);
|
|
player.off("seeking", seeking);
|
|
player.off("timeupdate", timeupdate);
|
|
};
|
|
}, [getPlayer, interactiveClient, scene]);
|
|
|
|
useEffect(() => {
|
|
const player = getPlayer();
|
|
if (!player) return;
|
|
|
|
// don't re-initialise the player unless the scene has changed
|
|
if (!file || scene.id === sceneId.current) return;
|
|
|
|
sceneId.current = scene.id;
|
|
|
|
setReady(false);
|
|
|
|
// reset on new scene
|
|
player.trackActivity().reset();
|
|
|
|
// always stop the interactive client on initialisation
|
|
interactiveClient.pause();
|
|
|
|
const isSafari = UAParser().browser.name?.includes("Safari");
|
|
const isLandscape = file.height && file.width && file.width > file.height;
|
|
const mobileUiOptions = {
|
|
fullscreen: {
|
|
enterOnRotate: true,
|
|
exitOnRotate: true,
|
|
lockOnRotate: true,
|
|
lockToLandscapeOnEnter: uiConfig?.disableMobileMediaAutoRotateEnabled
|
|
? false
|
|
: isLandscape,
|
|
},
|
|
touchControls: {
|
|
disabled: true,
|
|
},
|
|
};
|
|
if (!isSafari) {
|
|
player.mobileUi(mobileUiOptions);
|
|
}
|
|
|
|
function isDirect(src: URL) {
|
|
return (
|
|
src.pathname.endsWith("/stream") ||
|
|
src.pathname.endsWith("/stream.mpd") ||
|
|
src.pathname.endsWith("/stream.m3u8")
|
|
);
|
|
}
|
|
|
|
const { duration } = file;
|
|
const sourceSelector = player.sourceSelector();
|
|
sourceSelector.setSources(
|
|
scene.sceneStreams
|
|
.filter((stream) => {
|
|
const src = new URL(stream.url);
|
|
const isFileTranscode = !isDirect(src);
|
|
|
|
return !(isFileTranscode && isSafari);
|
|
})
|
|
.map((stream) => {
|
|
const src = new URL(stream.url);
|
|
|
|
return {
|
|
src: stream.url,
|
|
type: stream.mime_type ?? undefined,
|
|
label: stream.label ?? undefined,
|
|
offset: !isDirect(src),
|
|
duration,
|
|
};
|
|
})
|
|
);
|
|
|
|
function getDefaultLanguageCode() {
|
|
let languageCode = window.navigator.language;
|
|
|
|
if (languageCode.indexOf("-") !== -1) {
|
|
languageCode = languageCode.split("-")[0];
|
|
}
|
|
|
|
if (languageCode.indexOf("_") !== -1) {
|
|
languageCode = languageCode.split("_")[0];
|
|
}
|
|
|
|
return languageCode;
|
|
}
|
|
|
|
if (scene.captions && scene.captions.length > 0) {
|
|
const languageCode = getDefaultLanguageCode();
|
|
let hasDefault = false;
|
|
|
|
for (let caption of scene.captions) {
|
|
const lang = caption.language_code;
|
|
let label = lang;
|
|
if (languageMap.has(lang)) {
|
|
label = languageMap.get(lang)!;
|
|
}
|
|
|
|
label = label + " (" + caption.caption_type + ")";
|
|
const setAsDefault = !hasDefault && languageCode == lang;
|
|
if (setAsDefault) {
|
|
hasDefault = true;
|
|
}
|
|
sourceSelector.addTextTrack(
|
|
{
|
|
src: `${scene.paths.caption}?lang=${lang}&type=${caption.caption_type}`,
|
|
kind: "captions",
|
|
srclang: lang,
|
|
label: label,
|
|
default: setAsDefault,
|
|
},
|
|
false
|
|
);
|
|
}
|
|
}
|
|
|
|
auto.current =
|
|
autoplay ||
|
|
(interfaceConfig?.autostartVideo ?? false) ||
|
|
_initialTimestamp > 0;
|
|
|
|
const alwaysStartFromBeginning =
|
|
uiConfig?.alwaysStartFromBeginning ?? false;
|
|
const resumeTime = scene.resume_time ?? 0;
|
|
|
|
let startPosition = _initialTimestamp;
|
|
if (
|
|
!startPosition &&
|
|
!alwaysStartFromBeginning &&
|
|
file.duration > resumeTime
|
|
) {
|
|
startPosition = resumeTime;
|
|
}
|
|
|
|
setTime(startPosition);
|
|
|
|
player.load();
|
|
player.focus();
|
|
|
|
player.ready(() => {
|
|
player.vttThumbnails().src(scene.paths.vtt ?? null);
|
|
|
|
if (startPosition) {
|
|
player.currentTime(startPosition);
|
|
}
|
|
});
|
|
|
|
started.current = false;
|
|
|
|
return () => {
|
|
// stop the interactive client
|
|
interactiveClient.pause();
|
|
};
|
|
}, [
|
|
getPlayer,
|
|
file,
|
|
scene,
|
|
interactiveClient,
|
|
autoplay,
|
|
interfaceConfig?.autostartVideo,
|
|
uiConfig?.alwaysStartFromBeginning,
|
|
uiConfig?.disableMobileMediaAutoRotateEnabled,
|
|
_initialTimestamp,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const player = getPlayer();
|
|
if (!player) return;
|
|
|
|
if (scene.paths.screenshot) {
|
|
player.poster(scene.paths.screenshot);
|
|
} else {
|
|
player.poster("");
|
|
}
|
|
|
|
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,
|
|
}));
|
|
|
|
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();
|
|
}
|
|
// 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();
|
|
if (!player) return;
|
|
|
|
async function saveActivity(resumeTime: number, playDuration: number) {
|
|
if (!scene.id) return;
|
|
|
|
await sceneSaveActivity({
|
|
variables: {
|
|
id: scene.id,
|
|
playDuration,
|
|
resume_time: resumeTime,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function incrementPlayCount() {
|
|
if (!scene.id) return;
|
|
|
|
await sceneIncrementPlayCount({
|
|
variables: {
|
|
id: scene.id,
|
|
},
|
|
});
|
|
}
|
|
|
|
const activity = player.trackActivity();
|
|
activity.saveActivity = saveActivity;
|
|
activity.incrementPlayCount = incrementPlayCount;
|
|
activity.minimumPlayPercent = minimumPlayPercent;
|
|
activity.setEnabled(trackActivity);
|
|
}, [
|
|
getPlayer,
|
|
scene,
|
|
vrTag,
|
|
trackActivity,
|
|
minimumPlayPercent,
|
|
sceneIncrementPlayCount,
|
|
sceneSaveActivity,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const player = getPlayer();
|
|
if (!player) return;
|
|
|
|
player.loop(looping);
|
|
interactiveClient.setLooping(looping);
|
|
}, [getPlayer, interactiveClient, looping]);
|
|
|
|
useEffect(() => {
|
|
const player = getPlayer();
|
|
if (!player || !ready || !auto.current) {
|
|
return;
|
|
}
|
|
|
|
// check if we're waiting for the interactive client
|
|
if (
|
|
scene.interactive &&
|
|
interactiveClient.handyKey &&
|
|
currentScript !== scene.paths.funscript
|
|
) {
|
|
return;
|
|
}
|
|
|
|
player.play();
|
|
auto.current = false;
|
|
}, [getPlayer, scene, ready, interactiveClient, currentScript]);
|
|
|
|
// Attach handler for onComplete event
|
|
useEffect(() => {
|
|
const player = getPlayer();
|
|
if (!player) return;
|
|
|
|
player.on("ended", onComplete);
|
|
|
|
return () => player.off("ended");
|
|
}, [getPlayer, onComplete]);
|
|
|
|
function onScrubberScroll() {
|
|
if (started.current) {
|
|
getPlayer()?.pause();
|
|
}
|
|
}
|
|
|
|
function onScrubberSeek(seconds: number) {
|
|
if (started.current) {
|
|
getPlayer()?.currentTime(seconds);
|
|
} else {
|
|
setTime(seconds);
|
|
}
|
|
}
|
|
|
|
// Override spacebar to always pause/play
|
|
function onKeyDown(this: HTMLDivElement, event: KeyboardEvent) {
|
|
const player = getPlayer();
|
|
if (!player) return;
|
|
|
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
|
|
return;
|
|
}
|
|
if (event.key == " ") {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (player.paused()) {
|
|
player.play();
|
|
} else {
|
|
player.pause();
|
|
}
|
|
}
|
|
}
|
|
|
|
const isPortrait =
|
|
file && file.height && file.width && file.height > file.width;
|
|
|
|
return (
|
|
<div
|
|
className={cx("VideoPlayer", { portrait: isPortrait, "no-file": !file })}
|
|
onKeyDownCapture={onKeyDown}
|
|
>
|
|
<div className="video-wrapper" ref={videoRef} />
|
|
{scene.interactive &&
|
|
(interactiveState !== ConnectionState.Ready ||
|
|
getPlayer()?.paused()) && <SceneInteractiveStatus />}
|
|
{file && showScrubber && (
|
|
<ScenePlayerScrubber
|
|
file={file}
|
|
scene={scene}
|
|
time={time}
|
|
onSeek={onScrubberSeek}
|
|
onScroll={onScrubberScroll}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ScenePlayer;
|