mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +03:00
Improve Handy integration (#2555)
* Refactor interactive into context * Stop the interactive device when leaving page * Show interactive state if not ready * Handle navigation and looping
This commit is contained in:
@@ -35,6 +35,7 @@ import * as GQL from "./core/generated-graphql";
|
|||||||
import { LoadingIndicator, TITLE_SUFFIX } from "./components/Shared";
|
import { LoadingIndicator, TITLE_SUFFIX } from "./components/Shared";
|
||||||
import { ConfigurationProvider } from "./hooks/Config";
|
import { ConfigurationProvider } from "./hooks/Config";
|
||||||
import { ManualProvider } from "./components/Help/Manual";
|
import { ManualProvider } from "./components/Help/Manual";
|
||||||
|
import { InteractiveProvider } from "./hooks/Interactive/context";
|
||||||
|
|
||||||
initPolyfills();
|
initPolyfills();
|
||||||
|
|
||||||
@@ -150,12 +151,14 @@ export const App: React.FC = () => {
|
|||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<LightboxProvider>
|
<LightboxProvider>
|
||||||
<ManualProvider>
|
<ManualProvider>
|
||||||
|
<InteractiveProvider>
|
||||||
<Helmet
|
<Helmet
|
||||||
titleTemplate={`%s ${TITLE_SUFFIX}`}
|
titleTemplate={`%s ${TITLE_SUFFIX}`}
|
||||||
defaultTitle="Stash"
|
defaultTitle="Stash"
|
||||||
/>
|
/>
|
||||||
{maybeRenderNavbar()}
|
{maybeRenderNavbar()}
|
||||||
<div className="main container-fluid">{renderContent()}</div>
|
<div className="main container-fluid">{renderContent()}</div>
|
||||||
|
</InteractiveProvider>
|
||||||
</ManualProvider>
|
</ManualProvider>
|
||||||
</LightboxProvider>
|
</LightboxProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
* Show Handy status on scene player where applicable. ([#2555](https://github.com/stashapp/stash/pull/2555))
|
||||||
* Added recommendations to home page. ([#2571](https://github.com/stashapp/stash/pull/2571))
|
* Added recommendations to home page. ([#2571](https://github.com/stashapp/stash/pull/2571))
|
||||||
* Add support for VTT and SRT captions for scenes. ([#2462](https://github.com/stashapp/stash/pull/2462))
|
* Add support for VTT and SRT captions for scenes. ([#2462](https://github.com/stashapp/stash/pull/2462))
|
||||||
* Added option to require a number of scroll attempts before navigating to next/previous image in Lightbox. ([#2544](https://github.com/stashapp/stash/pull/2544))
|
* Added option to require a number of scroll attempts before navigating to next/previous image in Lightbox. ([#2544](https://github.com/stashapp/stash/pull/2544))
|
||||||
|
|
||||||
### 🎨 Improvements
|
### 🎨 Improvements
|
||||||
|
* Added Handy server sync button to Interface settings page. ([#2555](https://github.com/stashapp/stash/pull/2555))
|
||||||
* Changed playback rate options to be the same as those provided by YouTube. ([#2550](https://github.com/stashapp/stash/pull/2550))
|
* Changed playback rate options to be the same as those provided by YouTube. ([#2550](https://github.com/stashapp/stash/pull/2550))
|
||||||
* Display error message on fatal error when running stash with double-click in Windows. ([#2543](https://github.com/stashapp/stash/pull/2543))
|
* Display error message on fatal error when running stash with double-click in Windows. ([#2543](https://github.com/stashapp/stash/pull/2543))
|
||||||
|
|
||||||
### 🐛 Bug fixes
|
### 🐛 Bug fixes
|
||||||
|
* Fix long Handy initialisation delay. ([#2555](https://github.com/stashapp/stash/pull/2555))
|
||||||
* Fix lightbox autoplaying while offscreen. ([#2563](https://github.com/stashapp/stash/pull/2563))
|
* Fix lightbox autoplaying while offscreen. ([#2563](https://github.com/stashapp/stash/pull/2563))
|
||||||
* Fix playback rate resetting when seeking. ([#2550](https://github.com/stashapp/stash/pull/2550))
|
* Fix playback rate resetting when seeking. ([#2550](https://github.com/stashapp/stash/pull/2550))
|
||||||
* Fix video not starting when clicking scene scrubber. ([#2546](https://github.com/stashapp/stash/pull/2546))
|
* Fix video not starting when clicking scene scrubber. ([#2546](https://github.com/stashapp/stash/pull/2546))
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import VideoJS, { VideoJsPlayer, VideoJsPlayerOptions } from "video.js";
|
import VideoJS, { VideoJsPlayer, VideoJsPlayerOptions } from "video.js";
|
||||||
import "videojs-vtt-thumbnails-freetube";
|
import "videojs-vtt-thumbnails-freetube";
|
||||||
import "videojs-seek-buttons";
|
import "videojs-seek-buttons";
|
||||||
@@ -15,7 +21,11 @@ import cx from "classnames";
|
|||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
|
import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
|
||||||
import { ConfigurationContext } from "src/hooks/Config";
|
import { ConfigurationContext } from "src/hooks/Config";
|
||||||
import { Interactive } from "src/utils/interactive";
|
import {
|
||||||
|
ConnectionState,
|
||||||
|
InteractiveContext,
|
||||||
|
} from "src/hooks/Interactive/context";
|
||||||
|
import { SceneInteractiveStatus } from "src/hooks/Interactive/status";
|
||||||
import { languageMap } from "src/utils/caption";
|
import { languageMap } from "src/utils/caption";
|
||||||
|
|
||||||
export const VIDEO_PLAYER_ID = "VideoJsPlayer";
|
export const VIDEO_PLAYER_ID = "VideoJsPlayer";
|
||||||
@@ -117,11 +127,18 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
|||||||
|
|
||||||
const [time, setTime] = useState(0);
|
const [time, setTime] = useState(0);
|
||||||
|
|
||||||
const [interactiveClient] = useState(
|
const {
|
||||||
new Interactive(config?.handyKey || "", config?.funscriptOffset || 0)
|
interactive: interactiveClient,
|
||||||
);
|
uploadScript,
|
||||||
|
currentScript,
|
||||||
|
initialised: interactiveInitialised,
|
||||||
|
state: interactiveState,
|
||||||
|
} = React.useContext(InteractiveContext);
|
||||||
|
|
||||||
const [initialTimestamp] = useState(timestamp);
|
const [initialTimestamp] = useState(timestamp);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const started = useRef(false);
|
||||||
|
const interactiveReady = useRef(false);
|
||||||
|
|
||||||
const maxLoopDuration = config?.maximumLoopDuration ?? 0;
|
const maxLoopDuration = config?.maximumLoopDuration ?? 0;
|
||||||
|
|
||||||
@@ -188,10 +205,18 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scene?.interactive) {
|
if (scene?.interactive && interactiveInitialised) {
|
||||||
interactiveClient.uploadScript(scene.paths.funscript || "");
|
interactiveReady.current = false;
|
||||||
|
uploadScript(scene.paths.funscript || "").then(() => {
|
||||||
|
interactiveReady.current = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [interactiveClient, scene?.interactive, scene?.paths.funscript]);
|
}, [
|
||||||
|
uploadScript,
|
||||||
|
interactiveInitialised,
|
||||||
|
scene?.interactive,
|
||||||
|
scene?.paths.funscript,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (skipButtonsRef.current) {
|
if (skipButtonsRef.current) {
|
||||||
@@ -222,6 +247,24 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
const player = playerRef.current;
|
||||||
|
if (player && scene) {
|
||||||
|
started.current = true;
|
||||||
|
|
||||||
|
player
|
||||||
|
.play()
|
||||||
|
?.then(() => {
|
||||||
|
if (initialTimestamp > 0) {
|
||||||
|
player.currentTime(initialTimestamp);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (scene.paths.screenshot) player.poster(scene.paths.screenshot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [scene, initialTimestamp]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let prevCaptionOffset = 0;
|
let prevCaptionOffset = 0;
|
||||||
|
|
||||||
@@ -374,6 +417,10 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// always stop the interactive client on initialisation
|
||||||
|
interactiveClient.pause();
|
||||||
|
interactiveReady.current = false;
|
||||||
|
|
||||||
if (!scene || scene.id === sceneId.current) return;
|
if (!scene || scene.id === sceneId.current) return;
|
||||||
sceneId.current = scene.id;
|
sceneId.current = scene.id;
|
||||||
|
|
||||||
@@ -420,80 +467,75 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
|||||||
|
|
||||||
player.currentTime(0);
|
player.currentTime(0);
|
||||||
|
|
||||||
player.loop(
|
const looping =
|
||||||
!!scene.file.duration &&
|
!!scene.file.duration &&
|
||||||
maxLoopDuration !== 0 &&
|
maxLoopDuration !== 0 &&
|
||||||
scene.file.duration < maxLoopDuration
|
scene.file.duration < maxLoopDuration;
|
||||||
);
|
player.loop(looping);
|
||||||
|
interactiveClient.setLooping(looping);
|
||||||
|
|
||||||
player.on("loadstart", function (this: VideoJsPlayer) {
|
function loadstart(this: VideoJsPlayer) {
|
||||||
// handle offset after loading so that we get the correct current source
|
// handle offset after loading so that we get the correct current source
|
||||||
handleOffset(this);
|
handleOffset(this);
|
||||||
});
|
}
|
||||||
|
|
||||||
player.on("play", function (this: VideoJsPlayer) {
|
player.on("loadstart", loadstart);
|
||||||
player.poster("");
|
|
||||||
if (scene.interactive) {
|
function onPlay(this: VideoJsPlayer) {
|
||||||
|
this.poster("");
|
||||||
|
if (scene?.interactive && interactiveReady.current) {
|
||||||
interactiveClient.play(this.currentTime());
|
interactiveClient.play(this.currentTime());
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
player.on("play", onPlay);
|
||||||
|
|
||||||
player.on("pause", () => {
|
function pause() {
|
||||||
if (scene.interactive) {
|
|
||||||
interactiveClient.pause();
|
interactiveClient.pause();
|
||||||
}
|
}
|
||||||
});
|
player.on("pause", pause);
|
||||||
|
|
||||||
player.on("timeupdate", function (this: VideoJsPlayer) {
|
function timeupdate(this: VideoJsPlayer) {
|
||||||
if (scene.interactive) {
|
if (scene?.interactive && interactiveReady.current) {
|
||||||
interactiveClient.ensurePlaying(this.currentTime());
|
interactiveClient.ensurePlaying(this.currentTime());
|
||||||
}
|
}
|
||||||
setTime(this.currentTime());
|
setTime(this.currentTime());
|
||||||
});
|
}
|
||||||
|
player.on("timeupdate", timeupdate);
|
||||||
|
|
||||||
player.on("seeking", function (this: VideoJsPlayer) {
|
function seeking(this: VideoJsPlayer) {
|
||||||
this.play();
|
this.play();
|
||||||
});
|
}
|
||||||
|
player.on("seeking", seeking);
|
||||||
|
|
||||||
player.on("error", () => {
|
function error() {
|
||||||
handleError(true);
|
handleError(true);
|
||||||
});
|
}
|
||||||
|
player.on("error", error);
|
||||||
|
|
||||||
// changing source (eg when seeking) resets the playback rate
|
// changing source (eg when seeking) resets the playback rate
|
||||||
// so set the default in addition to the current rate
|
// so set the default in addition to the current rate
|
||||||
player.on("ratechange", function (this: VideoJsPlayer) {
|
function ratechange(this: VideoJsPlayer) {
|
||||||
this.defaultPlaybackRate(this.playbackRate());
|
this.defaultPlaybackRate(this.playbackRate());
|
||||||
});
|
}
|
||||||
|
player.on("ratechange", ratechange);
|
||||||
|
|
||||||
player.on("loadedmetadata", () => {
|
function loadedmetadata(this: VideoJsPlayer) {
|
||||||
if (!player.videoWidth() && !player.videoHeight()) {
|
if (!this.videoWidth() && !this.videoHeight()) {
|
||||||
// Occurs during preload when videos with supported audio/unsupported video are preloaded.
|
// 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.
|
// 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 is loaded which needs to be ignored.
|
||||||
const currentFile = player.currentSrc();
|
const currentFile = this.currentSrc();
|
||||||
if (currentFile != null && !currentFile.includes("m3u8")) {
|
if (currentFile != null && !currentFile.includes("m3u8")) {
|
||||||
// const play = !player.paused();
|
// const play = !player.paused();
|
||||||
// handleError(play);
|
// handleError(play);
|
||||||
player.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
|
this.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
player.on("loadedmetadata", loadedmetadata);
|
||||||
|
|
||||||
player.load();
|
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)
|
if ((player as any).vttThumbnails?.src)
|
||||||
(player as any).vttThumbnails?.src(scene?.paths.vtt);
|
(player as any).vttThumbnails?.src(scene?.paths.vtt);
|
||||||
else
|
else
|
||||||
@@ -501,6 +543,25 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
|||||||
src: scene?.paths.vtt,
|
src: scene?.paths.vtt,
|
||||||
showTimestamp: true,
|
showTimestamp: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setReady(true);
|
||||||
|
started.current = false;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setReady(false);
|
||||||
|
|
||||||
|
// stop the interactive client
|
||||||
|
interactiveClient.pause();
|
||||||
|
|
||||||
|
player.off("loadstart", loadstart);
|
||||||
|
player.off("play", onPlay);
|
||||||
|
player.off("pause", pause);
|
||||||
|
player.off("timeupdate", timeupdate);
|
||||||
|
player.off("seeking", seeking);
|
||||||
|
player.off("error", error);
|
||||||
|
player.off("ratechange", ratechange);
|
||||||
|
player.off("loadedmetadata", loadedmetadata);
|
||||||
|
};
|
||||||
}, [
|
}, [
|
||||||
scene,
|
scene,
|
||||||
config?.autostartVideo,
|
config?.autostartVideo,
|
||||||
@@ -508,6 +569,35 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
|||||||
initialTimestamp,
|
initialTimestamp,
|
||||||
autoplay,
|
autoplay,
|
||||||
interactiveClient,
|
interactiveClient,
|
||||||
|
start,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || started.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto =
|
||||||
|
autoplay || (config?.autostartVideo ?? false) || initialTimestamp > 0;
|
||||||
|
|
||||||
|
// check if we're waiting for the interactive client
|
||||||
|
const interactiveWaiting =
|
||||||
|
scene?.interactive &&
|
||||||
|
interactiveClient.handyKey &&
|
||||||
|
currentScript !== scene.paths.funscript;
|
||||||
|
|
||||||
|
if (scene && auto && !interactiveWaiting) {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
config?.autostartVideo,
|
||||||
|
initialTimestamp,
|
||||||
|
scene,
|
||||||
|
ready,
|
||||||
|
interactiveClient,
|
||||||
|
currentScript,
|
||||||
|
autoplay,
|
||||||
|
start,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -550,6 +640,9 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
|||||||
className="video-js vjs-big-play-centered"
|
className="video-js vjs-big-play-centered"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{scene?.interactive &&
|
||||||
|
(interactiveState !== ConnectionState.Ready ||
|
||||||
|
playerRef.current?.paused()) && <SceneInteractiveStatus />}
|
||||||
{scene && (
|
{scene && (
|
||||||
<ScenePlayerScrubber
|
<ScenePlayerScrubber
|
||||||
scene={scene}
|
scene={scene}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Form } from "react-bootstrap";
|
import { Button, Form } from "react-bootstrap";
|
||||||
import { useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { DurationInput, LoadingIndicator } from "src/components/Shared";
|
import { DurationInput, LoadingIndicator } from "src/components/Shared";
|
||||||
import { CheckboxGroup } from "./CheckboxGroup";
|
import { CheckboxGroup } from "./CheckboxGroup";
|
||||||
import { SettingSection } from "../SettingSection";
|
import { SettingSection } from "../SettingSection";
|
||||||
@@ -19,6 +19,11 @@ import {
|
|||||||
imageLightboxScrollModeIntlMap,
|
imageLightboxScrollModeIntlMap,
|
||||||
} from "src/core/enums";
|
} from "src/core/enums";
|
||||||
import { useInterfaceLocalForage } from "src/hooks";
|
import { useInterfaceLocalForage } from "src/hooks";
|
||||||
|
import {
|
||||||
|
ConnectionState,
|
||||||
|
connectionStateLabel,
|
||||||
|
InteractiveContext,
|
||||||
|
} from "src/hooks/Interactive/context";
|
||||||
|
|
||||||
const allMenuItems = [
|
const allMenuItems = [
|
||||||
{ id: "scenes", headingID: "scenes" },
|
{ id: "scenes", headingID: "scenes" },
|
||||||
@@ -38,6 +43,16 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||||||
SettingStateContext
|
SettingStateContext
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
interactive,
|
||||||
|
state: interactiveState,
|
||||||
|
error: interactiveError,
|
||||||
|
serverOffset: interactiveServerOffset,
|
||||||
|
initialised: interactiveInitialised,
|
||||||
|
initialise: initialiseInteractive,
|
||||||
|
sync: interactiveSync,
|
||||||
|
} = React.useContext(InteractiveContext);
|
||||||
|
|
||||||
const [, setInterfaceLocalForage] = useInterfaceLocalForage();
|
const [, setInterfaceLocalForage] = useInterfaceLocalForage();
|
||||||
|
|
||||||
function saveLightboxSettings(v: Partial<GQL.ConfigImageLightboxInput>) {
|
function saveLightboxSettings(v: Partial<GQL.ConfigImageLightboxInput>) {
|
||||||
@@ -397,6 +412,70 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||||||
value={iface.handyKey ?? undefined}
|
value={iface.handyKey ?? undefined}
|
||||||
onChange={(v) => saveInterface({ handyKey: v })}
|
onChange={(v) => saveInterface({ handyKey: v })}
|
||||||
/>
|
/>
|
||||||
|
{interactive.handyKey && (
|
||||||
|
<>
|
||||||
|
<div className="setting" id="handy-status">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "config.ui.handy_connection.status.heading",
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="value">
|
||||||
|
<FormattedMessage
|
||||||
|
id={connectionStateLabel(interactiveState)}
|
||||||
|
/>
|
||||||
|
{interactiveError && <span>: {interactiveError}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{!interactiveInitialised && (
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
interactiveState === ConnectionState.Connecting ||
|
||||||
|
interactiveState === ConnectionState.Syncing
|
||||||
|
}
|
||||||
|
onClick={() => initialiseInteractive()}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "config.ui.handy_connection.connect",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="setting" id="handy-server-offset">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "config.ui.handy_connection.server_offset.heading",
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="value">
|
||||||
|
{interactiveServerOffset.toFixed()}ms
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{interactiveInitialised && (
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
!interactiveInitialised ||
|
||||||
|
interactiveState === ConnectionState.Syncing
|
||||||
|
}
|
||||||
|
onClick={() => interactiveSync()}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "config.ui.handy_connection.sync",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<NumberSetting
|
<NumberSetting
|
||||||
headingID="config.ui.funscript_offset.heading"
|
headingID="config.ui.funscript_offset.heading"
|
||||||
subHeadingID="config.ui.funscript_offset.description"
|
subHeadingID="config.ui.funscript_offset.description"
|
||||||
|
|||||||
195
ui/v2.5/src/hooks/Interactive/context.tsx
Normal file
195
ui/v2.5/src/hooks/Interactive/context.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { ConfigurationContext } from "../Config";
|
||||||
|
import { useLocalForage } from "../LocalForage";
|
||||||
|
import { Interactive as InteractiveAPI } from "./interactive";
|
||||||
|
|
||||||
|
export enum ConnectionState {
|
||||||
|
Missing,
|
||||||
|
Disconnected,
|
||||||
|
Error,
|
||||||
|
Connecting,
|
||||||
|
Syncing,
|
||||||
|
Uploading,
|
||||||
|
Ready,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectionStateLabel(s: ConnectionState) {
|
||||||
|
const prefix = "handy_connection_status";
|
||||||
|
switch (s) {
|
||||||
|
case ConnectionState.Missing:
|
||||||
|
return `${prefix}.missing`;
|
||||||
|
case ConnectionState.Connecting:
|
||||||
|
return `${prefix}.connecting`;
|
||||||
|
case ConnectionState.Disconnected:
|
||||||
|
return `${prefix}.disconnected`;
|
||||||
|
case ConnectionState.Error:
|
||||||
|
return `${prefix}.error`;
|
||||||
|
case ConnectionState.Syncing:
|
||||||
|
return `${prefix}.syncing`;
|
||||||
|
case ConnectionState.Uploading:
|
||||||
|
return `${prefix}.uploading`;
|
||||||
|
case ConnectionState.Ready:
|
||||||
|
return `${prefix}.ready`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IState {
|
||||||
|
interactive: InteractiveAPI;
|
||||||
|
state: ConnectionState;
|
||||||
|
serverOffset: number;
|
||||||
|
initialised: boolean;
|
||||||
|
currentScript?: string;
|
||||||
|
error?: string;
|
||||||
|
initialise: () => Promise<void>;
|
||||||
|
uploadScript: (funscriptPath: string) => Promise<void>;
|
||||||
|
sync: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InteractiveContext = React.createContext<IState>({
|
||||||
|
interactive: new InteractiveAPI("", 0),
|
||||||
|
state: ConnectionState.Missing,
|
||||||
|
serverOffset: 0,
|
||||||
|
initialised: false,
|
||||||
|
initialise: () => {
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
uploadScript: () => {
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
sync: () => {
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const LOCAL_FORAGE_KEY = "interactive";
|
||||||
|
|
||||||
|
interface IInteractiveState {
|
||||||
|
serverOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InteractiveProvider: React.FC = ({ children }) => {
|
||||||
|
const [{ data: config }, setConfig] = useLocalForage<IInteractiveState>(
|
||||||
|
LOCAL_FORAGE_KEY,
|
||||||
|
{ serverOffset: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { configuration: stashConfig } = React.useContext(ConfigurationContext);
|
||||||
|
|
||||||
|
const [state, setState] = useState<ConnectionState>(ConnectionState.Missing);
|
||||||
|
const [handyKey, setHandyKey] = useState<string | undefined>(undefined);
|
||||||
|
const [currentScript, setCurrentScript] = useState<string | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const [scriptOffset, setScriptOffset] = useState<number>(0);
|
||||||
|
const [interactive] = useState<InteractiveAPI>(new InteractiveAPI("", 0));
|
||||||
|
|
||||||
|
const [initialised, setInitialised] = useState(false);
|
||||||
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const initialise = useCallback(async () => {
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
if (!config?.serverOffset) {
|
||||||
|
setState(ConnectionState.Syncing);
|
||||||
|
const offset = await interactive.sync();
|
||||||
|
setConfig({ serverOffset: offset });
|
||||||
|
setState(ConnectionState.Ready);
|
||||||
|
setInitialised(true);
|
||||||
|
} else {
|
||||||
|
interactive.setServerTimeOffset(config.serverOffset);
|
||||||
|
setState(ConnectionState.Connecting);
|
||||||
|
try {
|
||||||
|
await interactive.connect();
|
||||||
|
setState(ConnectionState.Ready);
|
||||||
|
setInitialised(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
setError(e.message ?? e.toString());
|
||||||
|
setState(ConnectionState.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [config, interactive, setConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!stashConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHandyKey(stashConfig.interface.handyKey ?? undefined);
|
||||||
|
setScriptOffset(stashConfig.interface.funscriptOffset ?? 0);
|
||||||
|
}, [stashConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldKey = interactive.handyKey;
|
||||||
|
|
||||||
|
interactive.handyKey = handyKey ?? "";
|
||||||
|
interactive.scriptOffset = scriptOffset;
|
||||||
|
|
||||||
|
if (oldKey !== interactive.handyKey && interactive.handyKey) {
|
||||||
|
initialise();
|
||||||
|
}
|
||||||
|
}, [handyKey, scriptOffset, config, interactive, initialise]);
|
||||||
|
|
||||||
|
const sync = useCallback(async () => {
|
||||||
|
if (
|
||||||
|
!interactive.handyKey ||
|
||||||
|
state === ConnectionState.Syncing ||
|
||||||
|
!initialised
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(ConnectionState.Syncing);
|
||||||
|
const offset = await interactive.sync();
|
||||||
|
setConfig({ serverOffset: offset });
|
||||||
|
setState(ConnectionState.Ready);
|
||||||
|
}, [interactive, state, setConfig, initialised]);
|
||||||
|
|
||||||
|
const uploadScript = useCallback(
|
||||||
|
async (funscriptPath: string) => {
|
||||||
|
interactive.pause();
|
||||||
|
if (
|
||||||
|
!interactive.handyKey ||
|
||||||
|
!funscriptPath ||
|
||||||
|
funscriptPath === currentScript
|
||||||
|
) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(ConnectionState.Uploading);
|
||||||
|
try {
|
||||||
|
await interactive.uploadScript(funscriptPath);
|
||||||
|
setCurrentScript(funscriptPath);
|
||||||
|
setState(ConnectionState.Ready);
|
||||||
|
} catch (e) {
|
||||||
|
setState(ConnectionState.Error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[interactive, currentScript]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InteractiveContext.Provider
|
||||||
|
value={{
|
||||||
|
interactive,
|
||||||
|
state,
|
||||||
|
error,
|
||||||
|
currentScript,
|
||||||
|
serverOffset: config?.serverOffset ?? 0,
|
||||||
|
initialised,
|
||||||
|
initialise,
|
||||||
|
uploadScript,
|
||||||
|
sync,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</InteractiveContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InteractiveProvider;
|
||||||
35
ui/v2.5/src/hooks/Interactive/interactive.scss
Normal file
35
ui/v2.5/src/hooks/Interactive/interactive.scss
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
div.scene-interactive-status {
|
||||||
|
opacity: 0.75;
|
||||||
|
padding: 0.75rem;
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
&.interactive-status-disconnected,
|
||||||
|
&.interactive-status-error svg {
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.interactive-status-connecting svg,
|
||||||
|
&.interactive-status-syncing svg,
|
||||||
|
&.interactive-status-uploading svg {
|
||||||
|
animation: 1s ease 0s infinite alternate fadepulse;
|
||||||
|
color: $warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.interactive-status-ready svg {
|
||||||
|
color: $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadepulse {
|
||||||
|
0% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
HandyMode,
|
HandyMode,
|
||||||
HsspSetupResult,
|
HsspSetupResult,
|
||||||
CsvUploadResponse,
|
CsvUploadResponse,
|
||||||
|
HandyFirmwareStatus,
|
||||||
} from "thehandy/lib/types";
|
} from "thehandy/lib/types";
|
||||||
|
|
||||||
interface IFunscript {
|
interface IFunscript {
|
||||||
@@ -92,10 +93,10 @@ async function uploadCsv(
|
|||||||
// Interactive currently uses the Handy API, but could be expanded to use buttplug.io
|
// Interactive currently uses the Handy API, but could be expanded to use buttplug.io
|
||||||
// via buttplugio/buttplug-rs-ffi's WASM module.
|
// via buttplugio/buttplug-rs-ffi's WASM module.
|
||||||
export class Interactive {
|
export class Interactive {
|
||||||
private _connected: boolean;
|
_connected: boolean;
|
||||||
private _playing: boolean;
|
_playing: boolean;
|
||||||
private _scriptOffset: number;
|
_scriptOffset: number;
|
||||||
private _handy: Handy;
|
_handy: Handy;
|
||||||
|
|
||||||
constructor(handyKey: string, scriptOffset: number) {
|
constructor(handyKey: string, scriptOffset: number) {
|
||||||
this._handy = new Handy();
|
this._handy = new Handy();
|
||||||
@@ -105,23 +106,42 @@ export class Interactive {
|
|||||||
this._playing = false;
|
this._playing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
const connected = await this._handy.getConnected();
|
||||||
|
if (!connected) {
|
||||||
|
throw new Error("Handy not connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// check the firmware and make sure it's compatible
|
||||||
|
const info = await this._handy.getInfo();
|
||||||
|
if (info.fwStatus === HandyFirmwareStatus.updateRequired) {
|
||||||
|
throw new Error("Handy firmware update required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set handyKey(key: string) {
|
||||||
|
this._handy.connectionKey = key;
|
||||||
|
}
|
||||||
|
|
||||||
get handyKey(): string {
|
get handyKey(): string {
|
||||||
return this._handy.connectionKey;
|
return this._handy.connectionKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set scriptOffset(offset: number) {
|
||||||
|
this._scriptOffset = offset;
|
||||||
|
}
|
||||||
|
|
||||||
async uploadScript(funscriptPath: string) {
|
async uploadScript(funscriptPath: string) {
|
||||||
if (!(this._handy.connectionKey && funscriptPath)) {
|
if (!(this._handy.connectionKey && funscriptPath)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Calibrates the latency between the browser client and the Handy server's
|
|
||||||
// This is done before a script upload to ensure a synchronized experience
|
|
||||||
await this._handy.getServerTimeOffset();
|
|
||||||
|
|
||||||
const csv = await fetch(funscriptPath)
|
const csv = await fetch(funscriptPath)
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((json) => convertFunscriptToCSV(json));
|
.then((json) => convertFunscriptToCSV(json));
|
||||||
const fileName = `${Math.round(Math.random() * 100000000)}.csv`;
|
const fileName = `${Math.round(Math.random() * 100000000)}.csv`;
|
||||||
const csvFile = new File([csv], fileName);
|
const csvFile = new File([csv], fileName);
|
||||||
|
|
||||||
const tempURL = await uploadCsv(csvFile).then((response) => response.url);
|
const tempURL = await uploadCsv(csvFile).then((response) => response.url);
|
||||||
|
|
||||||
await this._handy.setMode(HandyMode.hssp);
|
await this._handy.setMode(HandyMode.hssp);
|
||||||
@@ -131,6 +151,14 @@ export class Interactive {
|
|||||||
.then((result) => result === HsspSetupResult.downloaded);
|
.then((result) => result === HsspSetupResult.downloaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sync() {
|
||||||
|
return this._handy.getServerTimeOffset();
|
||||||
|
}
|
||||||
|
|
||||||
|
setServerTimeOffset(offset: number) {
|
||||||
|
this._handy.estimatedServerTimeOffset = offset;
|
||||||
|
}
|
||||||
|
|
||||||
async play(position: number) {
|
async play(position: number) {
|
||||||
if (!this._connected) {
|
if (!this._connected) {
|
||||||
return;
|
return;
|
||||||
@@ -157,4 +185,11 @@ export class Interactive {
|
|||||||
}
|
}
|
||||||
await this.play(position);
|
await this.play(position);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setLooping(looping: boolean) {
|
||||||
|
if (!this._connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._handy.setHsspLoop(looping);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
47
ui/v2.5/src/hooks/Interactive/status.tsx
Normal file
47
ui/v2.5/src/hooks/Interactive/status.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import React from "react";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import {
|
||||||
|
ConnectionState,
|
||||||
|
connectionStateLabel,
|
||||||
|
InteractiveContext,
|
||||||
|
} from "./context";
|
||||||
|
|
||||||
|
export const SceneInteractiveStatus: React.FC = ({}) => {
|
||||||
|
const { state, error } = React.useContext(InteractiveContext);
|
||||||
|
|
||||||
|
function getStateClass() {
|
||||||
|
switch (state) {
|
||||||
|
case ConnectionState.Connecting:
|
||||||
|
return "interactive-status-connecting";
|
||||||
|
case ConnectionState.Disconnected:
|
||||||
|
return "interactive-status-disconnected";
|
||||||
|
case ConnectionState.Error:
|
||||||
|
return "interactive-status-error";
|
||||||
|
case ConnectionState.Syncing:
|
||||||
|
return "interactive-status-uploading";
|
||||||
|
case ConnectionState.Uploading:
|
||||||
|
return "interactive-status-syncing";
|
||||||
|
case ConnectionState.Ready:
|
||||||
|
return "interactive-status-ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === ConnectionState.Missing) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`scene-interactive-status ${getStateClass()}`}>
|
||||||
|
<FontAwesomeIcon pulse icon="circle" size="xs" />
|
||||||
|
<span className="status-text">
|
||||||
|
<FormattedMessage id={connectionStateLabel(state)} />
|
||||||
|
{error && <span>: {error}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SceneInteractiveStatus;
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
@import "src/components/Wall/styles.scss";
|
@import "src/components/Wall/styles.scss";
|
||||||
@import "src/components/Tagger/styles.scss";
|
@import "src/components/Tagger/styles.scss";
|
||||||
@import "src/hooks/Lightbox/lightbox.scss";
|
@import "src/hooks/Lightbox/lightbox.scss";
|
||||||
|
@import "src/hooks/Interactive/interactive.scss";
|
||||||
@import "src/components/Dialogs/IdentifyDialog/styles.scss";
|
@import "src/components/Dialogs/IdentifyDialog/styles.scss";
|
||||||
@import "src/components/Dialogs/styles.scss";
|
@import "src/components/Dialogs/styles.scss";
|
||||||
@import "../node_modules/flag-icon-css/css/flag-icon.min.css";
|
@import "../node_modules/flag-icon-css/css/flag-icon.min.css";
|
||||||
|
|||||||
@@ -454,6 +454,16 @@
|
|||||||
"description": "Handy connection key to use for interactive scenes. Setting this key will allow Stash to share your current scene information with handyfeeling.com",
|
"description": "Handy connection key to use for interactive scenes. Setting this key will allow Stash to share your current scene information with handyfeeling.com",
|
||||||
"heading": "Handy Connection Key"
|
"heading": "Handy Connection Key"
|
||||||
},
|
},
|
||||||
|
"handy_connection": {
|
||||||
|
"connect": "Connect",
|
||||||
|
"server_offset": {
|
||||||
|
"heading": "Server Offset"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"heading": "Handy Connection Status"
|
||||||
|
},
|
||||||
|
"sync": "Sync"
|
||||||
|
},
|
||||||
"images": {
|
"images": {
|
||||||
"heading": "Images",
|
"heading": "Images",
|
||||||
"options": {
|
"options": {
|
||||||
@@ -736,6 +746,15 @@
|
|||||||
"NON_BINARY": "Non-Binary"
|
"NON_BINARY": "Non-Binary"
|
||||||
},
|
},
|
||||||
"hair_color": "Hair Colour",
|
"hair_color": "Hair Colour",
|
||||||
|
"handy_connection_status": {
|
||||||
|
"connecting": "Connecting",
|
||||||
|
"disconnected": "Disconnected",
|
||||||
|
"error": "Error connecting to Handy",
|
||||||
|
"missing": "Missing",
|
||||||
|
"ready": "Ready",
|
||||||
|
"syncing": "Syncing with server",
|
||||||
|
"uploading": "Uploading script"
|
||||||
|
},
|
||||||
"hasMarkers": "Has Markers",
|
"hasMarkers": "Has Markers",
|
||||||
"height": "Height",
|
"height": "Height",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
|
|||||||
Reference in New Issue
Block a user