mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
416 lines
10 KiB
TypeScript
416 lines
10 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import React, { useContext, useEffect, useRef, useState } from "react";
|
|
import VideoJS, { VideoJsPlayer, VideoJsPlayerOptions } from "video.js";
|
|
import "videojs-vtt-thumbnails-freetube";
|
|
import "videojs-seek-buttons";
|
|
import "videojs-landscape-fullscreen";
|
|
import "./live";
|
|
import "./PlaylistButtons";
|
|
import cx from "classnames";
|
|
|
|
import * as GQL from "src/core/generated-graphql";
|
|
import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
|
|
import { ConfigurationContext } from "src/hooks/Config";
|
|
import { Interactive } from "src/utils/interactive";
|
|
|
|
export const VIDEO_PLAYER_ID = "VideoJsPlayer";
|
|
|
|
function handleHotkeys(player: VideoJsPlayer, event: VideoJS.KeyboardEvent) {
|
|
function seekPercent(percent: number) {
|
|
const duration = player.duration();
|
|
const time = duration * percent;
|
|
player.currentTime(time);
|
|
}
|
|
|
|
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 39: // right arrow
|
|
player.currentTime(Math.max(player.duration(), player.currentTime() + 5));
|
|
break;
|
|
case 37: // left arrow
|
|
player.currentTime(Math.max(0, player.currentTime() - 5));
|
|
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;
|
|
}
|
|
}
|
|
|
|
interface IScenePlayerProps {
|
|
className?: string;
|
|
scene: GQL.SceneDataFragment | undefined | null;
|
|
timestamp: number;
|
|
autoplay?: boolean;
|
|
onComplete?: () => void;
|
|
onNext?: () => void;
|
|
onPrevious?: () => void;
|
|
}
|
|
|
|
export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
|
className,
|
|
autoplay,
|
|
scene,
|
|
timestamp,
|
|
onComplete,
|
|
onNext,
|
|
onPrevious,
|
|
}) => {
|
|
const { configuration } = useContext(ConfigurationContext);
|
|
const config = configuration?.interface;
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const playerRef = useRef<VideoJsPlayer | undefined>();
|
|
const sceneId = useRef<string | undefined>();
|
|
const skipButtonsRef = useRef<any>();
|
|
|
|
const [time, setTime] = useState(0);
|
|
|
|
const [interactiveClient] = useState(
|
|
new Interactive(config?.handyKey || "", config?.funscriptOffset || 0)
|
|
);
|
|
|
|
const [initialTimestamp] = useState(timestamp);
|
|
|
|
const maxLoopDuration = config?.maximumLoopDuration ?? 0;
|
|
|
|
useEffect(() => {
|
|
if (playerRef.current && timestamp >= 0) {
|
|
const player = playerRef.current;
|
|
player.play()?.then(() => {
|
|
player.currentTime(timestamp);
|
|
});
|
|
}
|
|
}, [timestamp]);
|
|
|
|
useEffect(() => {
|
|
const videoElement = videoRef.current;
|
|
if (!videoElement) return;
|
|
|
|
const options: VideoJsPlayerOptions = {
|
|
controls: true,
|
|
controlBar: {
|
|
pictureInPictureToggle: false,
|
|
volumePanel: {
|
|
inline: false,
|
|
},
|
|
},
|
|
nativeControlsForTouch: false,
|
|
playbackRates: [0.75, 1, 1.5, 2, 3, 4],
|
|
inactivityTimeout: 2000,
|
|
preload: "none",
|
|
userActions: {
|
|
hotkeys: function (event) {
|
|
const player = this as VideoJsPlayer;
|
|
handleHotkeys(player, event);
|
|
},
|
|
},
|
|
};
|
|
|
|
const player = VideoJS(videoElement, options);
|
|
|
|
(player as any).landscapeFullscreen({
|
|
fullscreen: {
|
|
enterOnRotate: true,
|
|
exitOnRotate: true,
|
|
alwaysInLandscapeMode: true,
|
|
iOS: false,
|
|
},
|
|
});
|
|
|
|
(player as any).offset();
|
|
|
|
player.focus();
|
|
playerRef.current = player;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (scene?.interactive) {
|
|
interactiveClient.uploadScript(scene.paths.funscript || "");
|
|
}
|
|
}, [interactiveClient, scene?.interactive, scene?.paths.funscript]);
|
|
|
|
useEffect(() => {
|
|
if (skipButtonsRef.current) {
|
|
skipButtonsRef.current.setForwardHandler(onNext);
|
|
skipButtonsRef.current.setBackwardHandler(onPrevious);
|
|
}
|
|
}, [onNext, onPrevious]);
|
|
|
|
useEffect(() => {
|
|
const player = playerRef.current;
|
|
if (player) {
|
|
player.seekButtons({
|
|
forward: 10,
|
|
back: 10,
|
|
});
|
|
|
|
skipButtonsRef.current = player.skipButtons() ?? undefined;
|
|
|
|
player.focus();
|
|
}
|
|
|
|
// Video player destructor
|
|
return () => {
|
|
if (playerRef.current) {
|
|
playerRef.current.dispose();
|
|
playerRef.current = undefined;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
function handleOffset(player: VideoJsPlayer) {
|
|
if (!scene) return;
|
|
|
|
const currentSrc = player.currentSrc();
|
|
|
|
const isDirect =
|
|
currentSrc.endsWith("/stream") || currentSrc.endsWith("/stream.m3u8");
|
|
if (!isDirect) {
|
|
(player as any).setOffsetDuration(scene.file.duration);
|
|
} else {
|
|
(player as any).clearOffsetDuration();
|
|
}
|
|
}
|
|
|
|
function handleError(play: boolean) {
|
|
const player = playerRef.current;
|
|
if (!player) return;
|
|
|
|
const currentFile = player.currentSource();
|
|
if (currentFile) {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`Source failed: ${currentFile.src}`);
|
|
player.focus();
|
|
}
|
|
|
|
if (tryNextStream()) {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`Trying next source in playlist: ${player.currentSrc()}`);
|
|
player.load();
|
|
if (play) {
|
|
player.play();
|
|
}
|
|
} else {
|
|
// eslint-disable-next-line no-console
|
|
console.log("No more sources in playlist.");
|
|
}
|
|
}
|
|
|
|
function tryNextStream() {
|
|
const player = playerRef.current;
|
|
if (!player) return;
|
|
|
|
const sources = player.currentSources();
|
|
|
|
if (sources.length > 1) {
|
|
sources.shift();
|
|
player.src(sources);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
if (!scene || scene.id === sceneId.current) return;
|
|
sceneId.current = scene.id;
|
|
|
|
const player = playerRef.current;
|
|
if (!player) return;
|
|
|
|
const auto =
|
|
autoplay || (config?.autostartVideo ?? false) || initialTimestamp > 0;
|
|
if (!auto && scene.paths?.screenshot) player.poster(scene.paths.screenshot);
|
|
else player.poster("");
|
|
|
|
// clear the offset before loading anything new.
|
|
// otherwise, the offset will be applied to the next file when
|
|
// currentTime is called.
|
|
(player as any).clearOffsetDuration();
|
|
player.src(
|
|
scene.sceneStreams.map((stream) => ({
|
|
src: stream.url,
|
|
type: stream.mime_type ?? undefined,
|
|
label: stream.label ?? undefined,
|
|
}))
|
|
);
|
|
player.currentTime(0);
|
|
|
|
player.loop(
|
|
!!scene.file.duration &&
|
|
maxLoopDuration !== 0 &&
|
|
scene.file.duration < maxLoopDuration
|
|
);
|
|
|
|
player.on("loadstart", function (this: VideoJsPlayer) {
|
|
// handle offset after loading so that we get the correct current source
|
|
handleOffset(this);
|
|
});
|
|
|
|
player.on("play", function (this: VideoJsPlayer) {
|
|
if (scene.interactive) {
|
|
interactiveClient.play(this.currentTime());
|
|
}
|
|
});
|
|
|
|
player.on("pause", () => {
|
|
if (scene.interactive) {
|
|
interactiveClient.pause();
|
|
}
|
|
});
|
|
|
|
player.on("timeupdate", function (this: VideoJsPlayer) {
|
|
if (scene.interactive) {
|
|
interactiveClient.ensurePlaying(this.currentTime());
|
|
}
|
|
|
|
setTime(this.currentTime());
|
|
});
|
|
|
|
player.on("seeking", function (this: VideoJsPlayer) {
|
|
// backwards compatibility - may want to remove this in future
|
|
this.play();
|
|
});
|
|
|
|
player.on("error", () => {
|
|
handleError(true);
|
|
});
|
|
|
|
player.on("loadedmetadata", () => {
|
|
if (!player.videoWidth() && !player.videoHeight()) {
|
|
// Occurs during preload when videos with supported audio/unsupported video are preloaded.
|
|
// Treat this as a decoding error and try the next source without playing.
|
|
// However on Safari we get an media event when m3u8 is loaded which needs to be ignored.
|
|
const currentFile = player.currentSrc();
|
|
if (currentFile != null && !currentFile.includes("m3u8")) {
|
|
// const play = !player.paused();
|
|
// handleError(play);
|
|
player.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
|
|
}
|
|
}
|
|
});
|
|
|
|
player.load();
|
|
|
|
if (auto) {
|
|
player
|
|
.play()
|
|
?.then(() => {
|
|
if (initialTimestamp > 0) {
|
|
player.currentTime(initialTimestamp);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (scene.paths.screenshot) player.poster(scene.paths.screenshot);
|
|
});
|
|
}
|
|
|
|
if ((player as any).vttThumbnails?.src)
|
|
(player as any).vttThumbnails?.src(scene?.paths.vtt);
|
|
else
|
|
(player as any).vttThumbnails({
|
|
src: scene?.paths.vtt,
|
|
showTimestamp: true,
|
|
});
|
|
}, [
|
|
scene,
|
|
config?.autostartVideo,
|
|
maxLoopDuration,
|
|
initialTimestamp,
|
|
autoplay,
|
|
interactiveClient,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
// Attach handler for onComplete event
|
|
const player = playerRef.current;
|
|
if (!player) return;
|
|
|
|
player.on("ended", () => {
|
|
onComplete?.();
|
|
});
|
|
|
|
return () => player.off("ended");
|
|
}, [onComplete]);
|
|
|
|
const onScrubberScrolled = () => {
|
|
playerRef.current?.pause();
|
|
};
|
|
const onScrubberSeek = (seconds: number) => {
|
|
playerRef.current?.currentTime(seconds);
|
|
};
|
|
|
|
const isPortrait =
|
|
scene &&
|
|
scene.file.height &&
|
|
scene.file.width &&
|
|
scene.file.height > scene.file.width;
|
|
|
|
return (
|
|
<div className={cx("VideoPlayer", { portrait: isPortrait })}>
|
|
<div data-vjs-player className={cx("video-wrapper", className)}>
|
|
<video
|
|
playsInline
|
|
ref={videoRef}
|
|
id={VIDEO_PLAYER_ID}
|
|
className="video-js vjs-big-play-centered"
|
|
/>
|
|
</div>
|
|
{scene && (
|
|
<ScenePlayerScrubber
|
|
scene={scene}
|
|
position={time}
|
|
onSeek={onScrubberSeek}
|
|
onScrolled={onScrubberScrolled}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const getPlayerPosition = () =>
|
|
VideoJS.getPlayer(VIDEO_PLAYER_ID).currentTime();
|