mirror of
https://github.com/stashapp/stash.git
synced 2026-01-05 21:30:17 -06:00
Fix deceptive WEBM playback in Safari (#3676)
* Fix babel deoptimization warning in vite dev server * Fix videojs HMR * Fix fake WEBM support in Safari
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, {
|
||||
KeyboardEvent,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -159,7 +160,6 @@ function getMarkerTitle(marker: MarkerFragment) {
|
||||
}
|
||||
|
||||
interface IScenePlayerProps {
|
||||
className?: string;
|
||||
scene: GQL.SceneDataFragment | undefined | null;
|
||||
hideScrubberOverride: boolean;
|
||||
autoplay?: boolean;
|
||||
@@ -172,7 +172,6 @@ interface IScenePlayerProps {
|
||||
}
|
||||
|
||||
export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
className,
|
||||
scene,
|
||||
hideScrubberOverride,
|
||||
autoplay,
|
||||
@@ -186,15 +185,14 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
const { configuration } = useContext(ConfigurationContext);
|
||||
const interfaceConfig = configuration?.interface;
|
||||
const uiConfig = configuration?.ui as IUIConfig | undefined;
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const playerRef = useRef<VideoJsPlayer>();
|
||||
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 [sessionInitialised, setSessionInitialised] = useState(false); // tracks play session. This is reset whenever ScenePlayer page is exited
|
||||
|
||||
const {
|
||||
interactive: interactiveClient,
|
||||
@@ -230,6 +228,12 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
[file, permitLoop, maxLoopDuration]
|
||||
);
|
||||
|
||||
const getPlayer = useCallback(() => {
|
||||
if (!_player) return null;
|
||||
if (_player.isDisposed()) return null;
|
||||
return _player;
|
||||
}, [_player]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hideScrubberOverride || fullscreen) {
|
||||
setShowScrubber(false);
|
||||
@@ -249,18 +253,19 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
sendSetTimestamp((value: number) => {
|
||||
const player = playerRef.current;
|
||||
const player = getPlayer();
|
||||
if (player && value >= 0) {
|
||||
player.play()?.then(() => {
|
||||
player.currentTime(value);
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [sendSetTimestamp]);
|
||||
}, [sendSetTimestamp, getPlayer]);
|
||||
|
||||
// Initialize VideoJS player
|
||||
useEffect(() => {
|
||||
const options: VideoJsPlayerOptions = {
|
||||
id: VIDEO_PLAYER_ID,
|
||||
controls: true,
|
||||
controlBar: {
|
||||
pictureInPictureToggle: false,
|
||||
@@ -292,6 +297,7 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
|
||||
inactivityTimeout: 2000,
|
||||
preload: "none",
|
||||
playsinline: true,
|
||||
userActions: {
|
||||
hotkeys: function (this: VideoJsPlayer, event) {
|
||||
handleHotkeys(this, event);
|
||||
@@ -314,33 +320,42 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
},
|
||||
};
|
||||
|
||||
const player = videojs(videoRef.current!, options);
|
||||
const videoEl = document.createElement("video-js");
|
||||
videoEl.setAttribute("data-vjs-player", "true");
|
||||
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 = (player as any).textTrackSettings;
|
||||
const settings = (vjs as any).textTrackSettings;
|
||||
settings.setValues({
|
||||
backgroundColor: "#000",
|
||||
backgroundOpacity: "0.5",
|
||||
});
|
||||
settings.updateDisplay();
|
||||
|
||||
player.focus();
|
||||
playerRef.current = player;
|
||||
vjs.focus();
|
||||
setPlayer(vjs);
|
||||
|
||||
// Video player destructor
|
||||
return () => {
|
||||
playerRef.current = undefined;
|
||||
player.dispose();
|
||||
vjs.dispose();
|
||||
videoEl.remove();
|
||||
setPlayer(undefined);
|
||||
|
||||
// reset sceneId to force reload sources
|
||||
sceneId.current = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const player = playerRef.current;
|
||||
const player = getPlayer();
|
||||
if (!player) return;
|
||||
const skipButtons = player.skipButtons();
|
||||
skipButtons.setForwardHandler(onNext);
|
||||
skipButtons.setBackwardHandler(onPrevious);
|
||||
}, [onNext, onPrevious]);
|
||||
}, [getPlayer, onNext, onPrevious]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scene?.interactive && interactiveInitialised) {
|
||||
@@ -358,6 +373,9 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
|
||||
// Player event handlers
|
||||
useEffect(() => {
|
||||
const player = getPlayer();
|
||||
if (!player) return;
|
||||
|
||||
function canplay(this: VideoJsPlayer) {
|
||||
if (initialTimestamp.current !== -1) {
|
||||
this.currentTime(initialTimestamp.current);
|
||||
@@ -381,9 +399,6 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
setFullscreen(this.isFullscreen());
|
||||
}
|
||||
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
player.on("canplay", canplay);
|
||||
player.on("playing", playing);
|
||||
player.on("loadstart", loadstart);
|
||||
@@ -395,9 +410,12 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
player.off("loadstart", loadstart);
|
||||
player.off("fullscreenchange", fullscreenchange);
|
||||
};
|
||||
}, []);
|
||||
}, [getPlayer]);
|
||||
|
||||
useEffect(() => {
|
||||
const player = getPlayer();
|
||||
if (!player) return;
|
||||
|
||||
function onplay(this: VideoJsPlayer) {
|
||||
this.persistVolume().enabled = true;
|
||||
if (scene?.interactive && interactiveReady.current) {
|
||||
@@ -424,9 +442,6 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
setTime(this.currentTime());
|
||||
}
|
||||
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
player.on("play", onplay);
|
||||
player.on("pause", pause);
|
||||
player.on("seeking", seeking);
|
||||
@@ -438,26 +453,22 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
player.off("seeking", seeking);
|
||||
player.off("timeupdate", timeupdate);
|
||||
};
|
||||
}, [interactiveClient, scene]);
|
||||
}, [getPlayer, interactiveClient, scene]);
|
||||
|
||||
useEffect(() => {
|
||||
const player = playerRef.current;
|
||||
const player = getPlayer();
|
||||
if (!player) return;
|
||||
|
||||
// don't re-initialise the player unless the scene has changed
|
||||
if (!scene || !file || scene.id === sceneId.current) return;
|
||||
|
||||
// if new scene was picked from playlist
|
||||
if (playerRef.current && sceneId.current) {
|
||||
if (trackActivity) {
|
||||
playerRef.current.trackActivity().reset();
|
||||
}
|
||||
}
|
||||
|
||||
sceneId.current = scene.id;
|
||||
|
||||
setReady(false);
|
||||
|
||||
// reset on new scene
|
||||
player.trackActivity().reset();
|
||||
|
||||
// always stop the interactive client on initialisation
|
||||
interactiveClient.pause();
|
||||
interactiveReady.current = false;
|
||||
@@ -546,19 +557,19 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
|
||||
const alwaysStartFromBeginning =
|
||||
uiConfig?.alwaysStartFromBeginning ?? false;
|
||||
const resumeTime = scene.resume_time ?? 0;
|
||||
|
||||
let startPosition = _initialTimestamp;
|
||||
if (
|
||||
!startPosition &&
|
||||
!(alwaysStartFromBeginning || sessionInitialised) &&
|
||||
file.duration > scene.resume_time!
|
||||
!alwaysStartFromBeginning &&
|
||||
file.duration > resumeTime
|
||||
) {
|
||||
startPosition = scene.resume_time!;
|
||||
startPosition = resumeTime;
|
||||
}
|
||||
|
||||
initialTimestamp.current = startPosition;
|
||||
setTime(startPosition);
|
||||
setSessionInitialised(true);
|
||||
|
||||
player.load();
|
||||
player.focus();
|
||||
@@ -574,11 +585,10 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
interactiveClient.pause();
|
||||
};
|
||||
}, [
|
||||
getPlayer,
|
||||
file,
|
||||
scene,
|
||||
trackActivity,
|
||||
interactiveClient,
|
||||
sessionInitialised,
|
||||
autoplay,
|
||||
interfaceConfig?.autostartVideo,
|
||||
uiConfig?.alwaysStartFromBeginning,
|
||||
@@ -586,7 +596,7 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const player = playerRef.current;
|
||||
const player = getPlayer();
|
||||
if (!player || !scene) return;
|
||||
|
||||
const markers = player.markers();
|
||||
@@ -603,10 +613,10 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
} else {
|
||||
player.poster("");
|
||||
}
|
||||
}, [scene]);
|
||||
}, [getPlayer, scene]);
|
||||
|
||||
useEffect(() => {
|
||||
const player = playerRef.current;
|
||||
const player = getPlayer();
|
||||
if (!player) return;
|
||||
|
||||
async function saveActivity(resumeTime: number, playDuration: number) {
|
||||
@@ -637,6 +647,7 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
activity.minimumPlayPercent = minimumPlayPercent;
|
||||
activity.setEnabled(trackActivity);
|
||||
}, [
|
||||
getPlayer,
|
||||
scene,
|
||||
trackActivity,
|
||||
minimumPlayPercent,
|
||||
@@ -645,15 +656,16 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const player = playerRef.current;
|
||||
const player = getPlayer();
|
||||
if (!player) return;
|
||||
|
||||
player.loop(looping);
|
||||
interactiveClient.setLooping(looping);
|
||||
}, [interactiveClient, looping]);
|
||||
}, [getPlayer, interactiveClient, looping]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scene || !ready || !auto.current) {
|
||||
const player = getPlayer();
|
||||
if (!player || !scene || !ready || !auto.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -666,9 +678,6 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
player.play()?.catch(() => {
|
||||
// Browser probably blocking non-muted autoplay, so mute and try again
|
||||
player.persistVolume().enabled = false;
|
||||
@@ -677,35 +686,36 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
player.play();
|
||||
});
|
||||
auto.current = false;
|
||||
}, [scene, ready, interactiveClient, currentScript]);
|
||||
}, [getPlayer, scene, ready, interactiveClient, currentScript]);
|
||||
|
||||
// Attach handler for onComplete event
|
||||
useEffect(() => {
|
||||
// Attach handler for onComplete event
|
||||
const player = playerRef.current;
|
||||
const player = getPlayer();
|
||||
if (!player) return;
|
||||
|
||||
player.on("ended", onComplete);
|
||||
|
||||
return () => player.off("ended");
|
||||
}, [onComplete]);
|
||||
}, [getPlayer, onComplete]);
|
||||
|
||||
const onScrubberScroll = () => {
|
||||
function onScrubberScroll() {
|
||||
if (started.current) {
|
||||
playerRef.current?.pause();
|
||||
getPlayer()?.pause();
|
||||
}
|
||||
};
|
||||
const onScrubberSeek = (seconds: number) => {
|
||||
}
|
||||
|
||||
function onScrubberSeek(seconds: number) {
|
||||
if (started.current) {
|
||||
playerRef.current?.currentTime(seconds);
|
||||
getPlayer()?.currentTime(seconds);
|
||||
} else {
|
||||
initialTimestamp.current = seconds;
|
||||
setTime(seconds);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Override spacebar to always pause/play
|
||||
function onKeyDown(this: HTMLDivElement, event: KeyboardEvent) {
|
||||
const player = playerRef.current;
|
||||
const player = getPlayer();
|
||||
if (!player) return;
|
||||
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
@@ -730,17 +740,10 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
className={cx("VideoPlayer", { portrait: isPortrait })}
|
||||
onKeyDownCapture={onKeyDown}
|
||||
>
|
||||
<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>
|
||||
<div className="video-wrapper" ref={videoRef} />
|
||||
{scene?.interactive &&
|
||||
(interactiveState !== ConnectionState.Ready ||
|
||||
playerRef.current?.paused()) && <SceneInteractiveStatus />}
|
||||
getPlayer()?.paused()) && <SceneInteractiveStatus />}
|
||||
{scene && file && showScrubber && (
|
||||
<ScenePlayerScrubber
|
||||
file={file}
|
||||
|
||||
@@ -99,15 +99,12 @@ class SourceSelectorPlugin extends videojs.getPlugin("plugin") {
|
||||
this.selectedIndex = this.sources.findIndex((src) => src === source);
|
||||
if (this.selectedIndex === -1) return;
|
||||
|
||||
const loadSrc = this.sources[this.selectedIndex];
|
||||
|
||||
const currentTime = player.currentTime();
|
||||
|
||||
// put the selected source at the top of the list
|
||||
const loadSources = [...this.sources];
|
||||
const selectedSrc = loadSources.splice(this.selectedIndex, 1)[0];
|
||||
loadSources.unshift(selectedSrc);
|
||||
|
||||
const paused = player.paused();
|
||||
player.src(loadSources);
|
||||
|
||||
player.src(loadSrc);
|
||||
player.one("canplay", () => {
|
||||
if (paused) {
|
||||
player.pause();
|
||||
@@ -128,10 +125,15 @@ class SourceSelectorPlugin extends videojs.getPlugin("plugin") {
|
||||
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.
|
||||
// However on Safari we get an media event when m3u8 or mpd is loaded which needs to be ignored.
|
||||
if (player.error() !== null) return;
|
||||
|
||||
const currentSrc = player.currentSrc();
|
||||
if (currentSrc !== null && !currentSrc.includes(".m3u8")) {
|
||||
if (currentSrc === null) return;
|
||||
|
||||
if (currentSrc.includes(".m3u8") || currentSrc.includes(".mpd")) {
|
||||
player.play();
|
||||
} else {
|
||||
player.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
|
||||
return;
|
||||
}
|
||||
@@ -156,7 +158,7 @@ class SourceSelectorPlugin extends videojs.getPlugin("plugin") {
|
||||
console.log(`Trying next source in playlist: '${newSource.label}'`);
|
||||
this.menu.setSources(this.sources);
|
||||
this.selectedIndex = 0;
|
||||
player.src(this.sources);
|
||||
player.src(newSource);
|
||||
player.load();
|
||||
player.play();
|
||||
} else {
|
||||
@@ -179,7 +181,7 @@ class SourceSelectorPlugin extends videojs.getPlugin("plugin") {
|
||||
}
|
||||
|
||||
this.sources = sources;
|
||||
this.player.src(this.sources);
|
||||
this.player.src(sources[0]);
|
||||
}
|
||||
|
||||
get textTracks(): HTMLTrackElement[] {
|
||||
|
||||
@@ -16,12 +16,12 @@ $sceneTabWidth: 450px;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
&.portrait .video-js {
|
||||
&.portrait .video-wrapper {
|
||||
height: 177.78vw;
|
||||
}
|
||||
}
|
||||
|
||||
.video-js {
|
||||
.video-wrapper {
|
||||
height: 56.25vw;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
@@ -29,6 +29,11 @@ $sceneTabWidth: 450px;
|
||||
@media (min-width: 1200px) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.video-js {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.vjs-button {
|
||||
outline: none;
|
||||
@@ -109,7 +114,7 @@ $sceneTabWidth: 450px;
|
||||
width: 100%;
|
||||
|
||||
.vjs-progress-holder {
|
||||
margin: 0 1rem;
|
||||
margin: 0 15px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -765,7 +765,6 @@ const SceneLoader: React.FC = () => {
|
||||
<div className={`scene-player-container ${collapsed ? "expanded" : ""}`}>
|
||||
<ScenePlayer
|
||||
key="ScenePlayer"
|
||||
className="w-100 m-sm-auto no-gutter"
|
||||
scene={scene}
|
||||
hideScrubberOverride={hideScrubber}
|
||||
autoplay={autoplay}
|
||||
|
||||
@@ -10,7 +10,11 @@ const sourcemap = process.env.VITE_APP_SOURCEMAPS === "true";
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(() => {
|
||||
let plugins = [
|
||||
react(),
|
||||
react({
|
||||
babel: {
|
||||
compact: true,
|
||||
},
|
||||
}),
|
||||
tsconfigPaths(),
|
||||
viteCompression({
|
||||
algorithm: "gzip",
|
||||
|
||||
Reference in New Issue
Block a user