mirror of
https://github.com/stashapp/stash.git
synced 2025-12-16 05:13:46 +01:00
Add Handy / Funscript support (#1377)
* Add funscript route to scenes Adds a /scene/:id/funscript route which serves a funscript file, if present. Current convention is that these are files stored with the same path, but with the extension ".funscript". * Look for funscript during scan This is stored in the Scene record and used to drive UI changes for funscript support. Currently, that's limited to a funscript link in the Scene's file info. * Add filtering and sorting for interactive * Add Handy connection key to interface config * Add Handy client and placeholder component. Uses defucilis/thehandy, but not thehandy-react as I had difficulty integrating the context with the existing components. Instead, the expensive calculation for the server time offset is put in localStorage for reuse. A debounce was added when scrubbing the video, as otherwise it spammed the Handy API with updates to the current offset.
This commit is contained in:
parent
33999d3e93
commit
547f6d79ad
32 changed files with 301 additions and 24 deletions
|
|
@ -53,6 +53,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
|
|||
cssEnabled
|
||||
language
|
||||
slideshowDelay
|
||||
handyKey
|
||||
}
|
||||
|
||||
fragment ConfigDLNAData on ConfigDLNAResult {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ fragment SlimSceneData on Scene {
|
|||
organized
|
||||
path
|
||||
phash
|
||||
interactive
|
||||
|
||||
file {
|
||||
size
|
||||
|
|
@ -31,6 +32,7 @@ fragment SlimSceneData on Scene {
|
|||
vtt
|
||||
chapters_vtt
|
||||
sprite
|
||||
funscript
|
||||
}
|
||||
|
||||
scene_markers {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ fragment SceneData on Scene {
|
|||
organized
|
||||
path
|
||||
phash
|
||||
interactive
|
||||
|
||||
file {
|
||||
size
|
||||
|
|
@ -30,6 +31,7 @@ fragment SceneData on Scene {
|
|||
webp
|
||||
vtt
|
||||
chapters_vtt
|
||||
funscript
|
||||
}
|
||||
|
||||
scene_markers {
|
||||
|
|
|
|||
|
|
@ -190,6 +190,8 @@ input ConfigInterfaceInput {
|
|||
language: String
|
||||
"""Slideshow Delay"""
|
||||
slideshowDelay: Int
|
||||
"""Handy Connection Key"""
|
||||
handyKey: String
|
||||
}
|
||||
|
||||
type ConfigInterfaceResult {
|
||||
|
|
@ -214,6 +216,8 @@ type ConfigInterfaceResult {
|
|||
language: String
|
||||
"""Slideshow Delay"""
|
||||
slideshowDelay: Int
|
||||
"""Handy Connection Key"""
|
||||
handyKey: String
|
||||
}
|
||||
|
||||
input ConfigDLNAInput {
|
||||
|
|
|
|||
|
|
@ -139,6 +139,8 @@ input SceneFilterType {
|
|||
stash_id: StringCriterionInput
|
||||
"""Filter by url"""
|
||||
url: StringCriterionInput
|
||||
"""Filter by interactive"""
|
||||
interactive: Boolean
|
||||
}
|
||||
|
||||
input MovieFilterType {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ type ScenePathsType {
|
|||
vtt: String # Resolver
|
||||
chapters_vtt: String # Resolver
|
||||
sprite: String # Resolver
|
||||
funscript: String # Resolver
|
||||
}
|
||||
|
||||
type SceneMovie {
|
||||
|
|
@ -37,6 +38,7 @@ type Scene {
|
|||
o_counter: Int
|
||||
path: String!
|
||||
phash: String
|
||||
interactive: Boolean!
|
||||
|
||||
file: SceneFileType! # Resolver
|
||||
paths: ScenePathsType! # Resolver
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*models.S
|
|||
vttPath := builder.GetSpriteVTTURL()
|
||||
spritePath := builder.GetSpriteURL()
|
||||
chaptersVttPath := builder.GetChaptersVTTURL()
|
||||
funscriptPath := builder.GetFunscriptURL()
|
||||
|
||||
return &models.ScenePathsType{
|
||||
Screenshot: &screenshotPath,
|
||||
Preview: &previewPath,
|
||||
|
|
@ -95,6 +97,7 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*models.S
|
|||
Vtt: &vttPath,
|
||||
ChaptersVtt: &chaptersVttPath,
|
||||
Sprite: &spritePath,
|
||||
Funscript: &funscriptPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -246,6 +246,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
|
|||
c.Set(config.CSSEnabled, *input.CSSEnabled)
|
||||
}
|
||||
|
||||
if input.HandyKey != nil {
|
||||
c.Set(config.HandyKey, *input.HandyKey)
|
||||
}
|
||||
|
||||
if err := c.Write(); err != nil {
|
||||
return makeConfigInterfaceResult(), err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
|||
cssEnabled := config.GetCSSEnabled()
|
||||
language := config.GetLanguage()
|
||||
slideshowDelay := config.GetSlideshowDelay()
|
||||
handyKey := config.GetHandyKey()
|
||||
|
||||
return &models.ConfigInterfaceResult{
|
||||
MenuItems: menuItems,
|
||||
|
|
@ -108,6 +109,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
|||
CSSEnabled: &cssEnabled,
|
||||
Language: &language,
|
||||
SlideshowDelay: &slideshowDelay,
|
||||
HandyKey: &handyKey,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ func (rs sceneRoutes) Routes() chi.Router {
|
|||
r.Get("/preview", rs.Preview)
|
||||
r.Get("/webp", rs.Webp)
|
||||
r.Get("/vtt/chapter", rs.ChapterVtt)
|
||||
r.Get("/funscript", rs.Funscript)
|
||||
|
||||
r.Get("/scene_marker/{sceneMarkerId}/stream", rs.SceneMarkerStream)
|
||||
r.Get("/scene_marker/{sceneMarkerId}/preview", rs.SceneMarkerPreview)
|
||||
|
|
@ -255,6 +256,12 @@ func (rs sceneRoutes) ChapterVtt(w http.ResponseWriter, r *http.Request) {
|
|||
_, _ = w.Write([]byte(vtt))
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) Funscript(w http.ResponseWriter, r *http.Request) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
funscript := utils.GetFunscriptPath(scene.Path)
|
||||
utils.ServeFileNoCache(w, r, funscript)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
w.Header().Set("Content-Type", "text/vtt")
|
||||
|
|
|
|||
|
|
@ -58,3 +58,7 @@ func (b SceneURLBuilder) GetSceneMarkerStreamURL(sceneMarkerID int) string {
|
|||
func (b SceneURLBuilder) GetSceneMarkerStreamPreviewURL(sceneMarkerID int) string {
|
||||
return b.BaseURL + "/scene/" + b.SceneID + "/scene_marker/" + strconv.Itoa(sceneMarkerID) + "/preview"
|
||||
}
|
||||
|
||||
func (b SceneURLBuilder) GetFunscriptURL() string {
|
||||
return b.BaseURL + "/scene/" + b.SceneID + "/funscript"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import (
|
|||
var DB *sqlx.DB
|
||||
var WriteMu *sync.Mutex
|
||||
var dbPath string
|
||||
var appSchemaVersion uint = 22
|
||||
var appSchemaVersion uint = 23
|
||||
var databaseSchemaVersion uint
|
||||
|
||||
var (
|
||||
|
|
|
|||
1
pkg/database/migrations/23_scenes_interactive.up.sql
Normal file
1
pkg/database/migrations/23_scenes_interactive.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `scenes` ADD COLUMN `interactive` boolean not null default '0';
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
|
|
@ -123,6 +122,7 @@ const ShowStudioAsText = "show_studio_as_text"
|
|||
const CSSEnabled = "cssEnabled"
|
||||
const WallPlayback = "wall_playback"
|
||||
const SlideshowDelay = "slideshow_delay"
|
||||
const HandyKey = "handy_key"
|
||||
|
||||
// DLNA options
|
||||
const DLNAServerName = "dlna.server_name"
|
||||
|
|
@ -633,6 +633,10 @@ func (i *Instance) GetCSSEnabled() bool {
|
|||
return viper.GetBool(CSSEnabled)
|
||||
}
|
||||
|
||||
func (i *Instance) GetHandyKey() string {
|
||||
return viper.GetString(HandyKey)
|
||||
}
|
||||
|
||||
// GetDLNAServerName returns the visible name of the DLNA server. If empty,
|
||||
// "stash" will be used.
|
||||
func (i *Instance) GetDLNAServerName() string {
|
||||
|
|
|
|||
|
|
@ -295,6 +295,12 @@ func (t *ScanTask) getFileModTime() (time.Time, error) {
|
|||
return ret, nil
|
||||
}
|
||||
|
||||
func (t *ScanTask) getInteractive() bool {
|
||||
_, err := os.Stat(utils.GetFunscriptPath(t.FilePath))
|
||||
return err == nil
|
||||
|
||||
}
|
||||
|
||||
func (t *ScanTask) isFileModified(fileModTime time.Time, modTime models.NullSQLiteTimestamp) bool {
|
||||
return !modTime.Timestamp.Equal(fileModTime)
|
||||
}
|
||||
|
|
@ -376,6 +382,7 @@ func (t *ScanTask) scanScene() *models.Scene {
|
|||
if err != nil {
|
||||
return logError(err)
|
||||
}
|
||||
interactive := t.getInteractive()
|
||||
|
||||
if s != nil {
|
||||
// if file mod time is not set, set it now
|
||||
|
|
@ -484,6 +491,20 @@ func (t *ScanTask) scanScene() *models.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
if s.Interactive != interactive {
|
||||
if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error {
|
||||
qb := r.Scene()
|
||||
scenePartial := models.ScenePartial{
|
||||
ID: s.ID,
|
||||
Interactive: &interactive,
|
||||
}
|
||||
_, err := qb.Update(scenePartial)
|
||||
return err
|
||||
}); err != nil {
|
||||
return logError(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -549,8 +570,9 @@ func (t *ScanTask) scanScene() *models.Scene {
|
|||
} else {
|
||||
logger.Infof("%s already exists. Updating path...", t.FilePath)
|
||||
scenePartial := models.ScenePartial{
|
||||
ID: s.ID,
|
||||
Path: &t.FilePath,
|
||||
ID: s.ID,
|
||||
Path: &t.FilePath,
|
||||
Interactive: &interactive,
|
||||
}
|
||||
if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error {
|
||||
_, err := r.Scene().Update(scenePartial)
|
||||
|
|
@ -580,8 +602,9 @@ func (t *ScanTask) scanScene() *models.Scene {
|
|||
Timestamp: fileModTime,
|
||||
Valid: true,
|
||||
},
|
||||
CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
Interactive: interactive,
|
||||
}
|
||||
|
||||
if t.UseFileMetadata {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ type Scene struct {
|
|||
Phash sql.NullInt64 `db:"phash,omitempty" json:"phash"`
|
||||
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
|
||||
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
|
||||
Interactive bool `db:"interactive" json:"interactive"`
|
||||
}
|
||||
|
||||
// ScenePartial represents part of a Scene object. It is used to update
|
||||
|
|
@ -62,6 +63,7 @@ type ScenePartial struct {
|
|||
Phash *sql.NullInt64 `db:"phash,omitempty" json:"phash"`
|
||||
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"`
|
||||
UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"`
|
||||
Interactive *bool `db:"interactive" json:"interactive"`
|
||||
}
|
||||
|
||||
// GetTitle returns the title of the scene. If the Title field is empty,
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@ func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *fi
|
|||
query.handleCriterionFunc(sceneIsMissingCriterionHandler(qb, sceneFilter.IsMissing))
|
||||
query.handleCriterionFunc(stringCriterionHandler(sceneFilter.URL, "scenes.url"))
|
||||
query.handleCriterionFunc(stringCriterionHandler(sceneFilter.StashID, "scene_stash_ids.stash_id"))
|
||||
query.handleCriterionFunc(boolCriterionHandler(sceneFilter.Interactive, "scenes.interactive"))
|
||||
|
||||
query.handleCriterionFunc(sceneTagsCriterionHandler(qb, sceneFilter.Tags))
|
||||
query.handleCriterionFunc(sceneTagCountCriterionHandler(qb, sceneFilter.TagCount))
|
||||
|
|
|
|||
|
|
@ -296,3 +296,11 @@ func GetNameFromPath(path string, stripExtension bool) string {
|
|||
}
|
||||
return fn
|
||||
}
|
||||
|
||||
// GetFunscriptPath returns the path of a file
|
||||
// with the extension changed to .funscript
|
||||
func GetFunscriptPath(path string) string {
|
||||
ext := filepath.Ext(path)
|
||||
fn := strings.TrimSuffix(path, ext)
|
||||
return fn + ".funscript"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@
|
|||
"sass": "^1.32.5",
|
||||
"string.prototype.replaceall": "^1.0.4",
|
||||
"subscriptions-transport-ws": "^0.9.18",
|
||||
"thehandy": "^0.2.7",
|
||||
"universal-cookie": "^4.0.4",
|
||||
"yup": "^0.32.9"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
### ✨ New Features
|
||||
* Added Handy/Funscript support. ([#1377](https://github.com/stashapp/stash/pull/1377))
|
||||
* Added Performers tab to Studio page. ([#1405](https://github.com/stashapp/stash/pull/1405))
|
||||
* Added [DLNA server](/settings?tab=dlna). ([#1364](https://github.com/stashapp/stash/pull/1364))
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import SceneFilenameParser from "src/docs/en/SceneFilenameParser.md";
|
|||
import KeyboardShortcuts from "src/docs/en/KeyboardShortcuts.md";
|
||||
import Help from "src/docs/en/Help.md";
|
||||
import Deduplication from "src/docs/en/Deduplication.md";
|
||||
import Interactive from "src/docs/en/Interactive.md";
|
||||
import { MarkdownPage } from "../Shared/MarkdownPage";
|
||||
|
||||
interface IManualProps {
|
||||
|
|
@ -92,6 +93,11 @@ export const Manual: React.FC<IManualProps> = ({
|
|||
title: "Dupe Checker",
|
||||
content: Deduplication,
|
||||
},
|
||||
{
|
||||
key: "Interactive.md",
|
||||
title: "Interactivity",
|
||||
content: Interactive,
|
||||
},
|
||||
{
|
||||
key: "KeyboardShortcuts.md",
|
||||
title: "Keyboard Shortcuts",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import * as GQL from "src/core/generated-graphql";
|
|||
import { useConfiguration } from "src/core/StashService";
|
||||
import { JWUtils } from "src/utils";
|
||||
import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
|
||||
import { Interactive } from "../../utils/interactive";
|
||||
|
||||
interface IScenePlayerProps {
|
||||
className?: string;
|
||||
|
|
@ -22,8 +23,8 @@ interface IScenePlayerState {
|
|||
scrubberPosition: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
config: Record<string, any>;
|
||||
interactiveClient: Interactive;
|
||||
}
|
||||
|
||||
export class ScenePlayerImpl extends React.Component<
|
||||
IScenePlayerProps,
|
||||
IScenePlayerState
|
||||
|
|
@ -50,16 +51,15 @@ export class ScenePlayerImpl extends React.Component<
|
|||
|
||||
this.onScrubberSeek = this.onScrubberSeek.bind(this);
|
||||
this.onScrubberScrolled = this.onScrubberScrolled.bind(this);
|
||||
|
||||
this.state = {
|
||||
scrubberPosition: 0,
|
||||
config: this.makeJWPlayerConfig(props.scene),
|
||||
interactiveClient: new Interactive(this.props.config?.handyKey || ""),
|
||||
};
|
||||
|
||||
// Default back to Direct Streaming
|
||||
localStorage.removeItem("jwplayer.qualityLabel");
|
||||
}
|
||||
|
||||
public UNSAFE_componentWillReceiveProps(props: IScenePlayerProps) {
|
||||
if (props.scene !== this.props.scene) {
|
||||
this.setState((state) => ({
|
||||
|
|
@ -88,8 +88,11 @@ export class ScenePlayerImpl extends React.Component<
|
|||
this.player.setPlaybackRate(1);
|
||||
}
|
||||
onPause() {
|
||||
if (this.player.getState().paused) this.player.play();
|
||||
else this.player.pause();
|
||||
if (this.player.getState().paused) {
|
||||
this.player.play();
|
||||
} else {
|
||||
this.player.pause();
|
||||
}
|
||||
}
|
||||
|
||||
private onReady() {
|
||||
|
|
@ -126,6 +129,24 @@ export class ScenePlayerImpl extends React.Component<
|
|||
this.player.seek(this.props.timestamp);
|
||||
}
|
||||
});
|
||||
|
||||
this.player.on("play", () => {
|
||||
if (this.props.scene.interactive) {
|
||||
this.state.interactiveClient.play(this.player.getPosition());
|
||||
}
|
||||
});
|
||||
|
||||
this.player.on("pause", () => {
|
||||
if (this.props.scene.interactive) {
|
||||
this.state.interactiveClient.pause();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.props.scene.interactive) {
|
||||
this.state.interactiveClient.uploadScript(
|
||||
this.props.scene.paths.funscript || ""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private onSeeked() {
|
||||
|
|
@ -140,6 +161,9 @@ export class ScenePlayerImpl extends React.Component<
|
|||
if (difference > 1) {
|
||||
this.lastTime = position;
|
||||
this.setState({ scrubberPosition: position });
|
||||
if (this.props.scene.interactive) {
|
||||
this.state.interactiveClient.ensurePlaying(position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import React, {
|
|||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { debounce } from "lodash";
|
||||
import { Button } from "react-bootstrap";
|
||||
import axios from "axios";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
|
|
@ -80,6 +82,10 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (
|
|||
const velocity = useRef(0);
|
||||
|
||||
const _position = useRef(0);
|
||||
const onSeek = useMemo(() => debounce(props.onSeek, 1000), [props.onSeek]);
|
||||
const onScrolled = useMemo(() => debounce(props.onScrolled, 1000), [
|
||||
props.onScrolled,
|
||||
]);
|
||||
const getPosition = useCallback(() => _position.current, []);
|
||||
const setPosition = useCallback(
|
||||
(newPostion: number, shouldEmit: boolean = true) => {
|
||||
|
|
@ -87,7 +93,7 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (
|
|||
return;
|
||||
}
|
||||
if (shouldEmit) {
|
||||
props.onScrolled();
|
||||
onScrolled();
|
||||
}
|
||||
|
||||
const midpointOffset = scrubberSliderEl.current.clientWidth / 2;
|
||||
|
|
@ -108,7 +114,7 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (
|
|||
scrubberSliderEl.current.clientWidth;
|
||||
positionIndicatorEl.current.style.transform = `translateX(${indicatorPosition}px)`;
|
||||
},
|
||||
[props]
|
||||
[onScrolled]
|
||||
);
|
||||
|
||||
const [spriteItems, setSpriteItems] = useState<ISceneSpriteItem[]>([]);
|
||||
|
|
@ -203,7 +209,7 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (
|
|||
}
|
||||
|
||||
if (seekSeconds) {
|
||||
props.onSeek(seekSeconds);
|
||||
onSeek(seekSeconds);
|
||||
}
|
||||
} else if (Math.abs(velocity.current) > 25) {
|
||||
const newPosition = getPosition() + velocity.current * 10;
|
||||
|
|
|
|||
|
|
@ -232,6 +232,19 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
|||
}
|
||||
}
|
||||
|
||||
function renderFunscript() {
|
||||
if (props.scene.interactive) {
|
||||
return (
|
||||
<div className="row">
|
||||
<span className="col-4">Funscript</span>
|
||||
<a href={props.scene.paths.funscript ?? ""} className="col-8">
|
||||
<TruncatedText text={props.scene.paths.funscript} />
|
||||
</a>{" "}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container scene-file-info">
|
||||
{renderOSHash()}
|
||||
|
|
@ -239,6 +252,7 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
|||
{renderPhash()}
|
||||
{renderPath()}
|
||||
{renderStream()}
|
||||
{renderFunscript()}
|
||||
{renderFileSize()}
|
||||
{renderDuration()}
|
||||
{renderDimensions()}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||
const [css, setCSS] = useState<string>();
|
||||
const [cssEnabled, setCSSEnabled] = useState<boolean>(false);
|
||||
const [language, setLanguage] = useState<string>("en");
|
||||
const [handyKey, setHandyKey] = useState<string>();
|
||||
|
||||
const [updateInterfaceConfig] = useConfigureInterface({
|
||||
menuItems: menuItemIds,
|
||||
|
|
@ -47,6 +48,7 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||
cssEnabled,
|
||||
language,
|
||||
slideshowDelay,
|
||||
handyKey,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -62,6 +64,7 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||
setCSSEnabled(iCfg?.cssEnabled ?? false);
|
||||
setLanguage(iCfg?.language ?? "en-US");
|
||||
setSlideshowDelay(iCfg?.slideshowDelay ?? 5000);
|
||||
setHandyKey(iCfg?.handyKey ?? "");
|
||||
}, [config]);
|
||||
|
||||
async function onSave() {
|
||||
|
|
@ -235,6 +238,20 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group>
|
||||
<h5>Handy Connection Key</h5>
|
||||
<Form.Control
|
||||
className="col col-sm-6 text-input"
|
||||
value={handyKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setHandyKey(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
Handy connection key to use for interactive scenes.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<hr />
|
||||
<Button variant="primary" onClick={() => onSave()}>
|
||||
Save
|
||||
|
|
|
|||
7
ui/v2.5/src/docs/en/Interactive.md
Normal file
7
ui/v2.5/src/docs/en/Interactive.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
Stash currently supports syncing with Handy devices, using funscript files.
|
||||
|
||||
In order for stash to connect to your Handy device, the Handy Connection Key must be entered in Settings -> Interface.
|
||||
|
||||
Funscript files must be in the same directory as the matching video file and must have the same base name. For example, a funscript file for `video.mp4` must be named `video.funscript`. A scan must be run to update scenes with matching funscript files.
|
||||
|
||||
Scenes with funscript files can be filtered with the `interactive` criterion.
|
||||
|
|
@ -53,7 +53,8 @@ export type CriterionType =
|
|||
| "performer_count"
|
||||
| "death_year"
|
||||
| "url"
|
||||
| "stash_id";
|
||||
| "stash_id"
|
||||
| "interactive";
|
||||
|
||||
type Option = string | number | IOptionType;
|
||||
export type CriterionValue = string | number | ILabeledId[];
|
||||
|
|
@ -153,6 +154,8 @@ export abstract class Criterion {
|
|||
return "URL";
|
||||
case "stash_id":
|
||||
return "StashID";
|
||||
case "interactive":
|
||||
return "Interactive";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
16
ui/v2.5/src/models/list-filter/criteria/interactive.ts
Normal file
16
ui/v2.5/src/models/list-filter/criteria/interactive.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { CriterionModifier } from "src/core/generated-graphql";
|
||||
import { Criterion, CriterionType, ICriterionOption } from "./criterion";
|
||||
|
||||
export class InteractiveCriterion extends Criterion {
|
||||
public type: CriterionType = "interactive";
|
||||
public parameterName: string = "interactive";
|
||||
public modifier = CriterionModifier.Equals;
|
||||
public modifierOptions = [];
|
||||
public options: string[] = [true.toString(), false.toString()];
|
||||
public value: string = "";
|
||||
}
|
||||
|
||||
export class InteractiveCriterionOption implements ICriterionOption {
|
||||
public label: string = Criterion.getLabel("interactive");
|
||||
public value: CriterionType = "interactive";
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import { TagsCriterion } from "./tags";
|
|||
import { GenderCriterion } from "./gender";
|
||||
import { MoviesCriterion } from "./movies";
|
||||
import { GalleriesCriterion } from "./galleries";
|
||||
import { InteractiveCriterion } from "./interactive";
|
||||
|
||||
export function makeCriteria(type: CriterionType = "none") {
|
||||
switch (type) {
|
||||
|
|
@ -109,5 +110,7 @@ export function makeCriteria(type: CriterionType = "none") {
|
|||
case "url":
|
||||
case "stash_id":
|
||||
return new StringCriterion(type, type);
|
||||
case "interactive":
|
||||
return new InteractiveCriterion();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,10 @@ import { DisplayMode, FilterMode } from "./types";
|
|||
import { GenderCriterionOption, GenderCriterion } from "./criteria/gender";
|
||||
import { MoviesCriterionOption, MoviesCriterion } from "./criteria/movies";
|
||||
import { GalleriesCriterion } from "./criteria/galleries";
|
||||
import {
|
||||
InteractiveCriterion,
|
||||
InteractiveCriterionOption,
|
||||
} from "./criteria/interactive";
|
||||
|
||||
interface IQueryParameters {
|
||||
perPage?: string;
|
||||
|
|
@ -136,6 +140,7 @@ export class ListFilterModel {
|
|||
"performer_count",
|
||||
"random",
|
||||
"movie_scene_number",
|
||||
"interactive",
|
||||
];
|
||||
this.displayModeOptions = [
|
||||
DisplayMode.Grid,
|
||||
|
|
@ -162,6 +167,7 @@ export class ListFilterModel {
|
|||
new MoviesCriterionOption(),
|
||||
ListFilterModel.createCriterionOption("url"),
|
||||
ListFilterModel.createCriterionOption("stash_id"),
|
||||
new InteractiveCriterionOption(),
|
||||
];
|
||||
break;
|
||||
case FilterMode.Images:
|
||||
|
|
@ -671,6 +677,11 @@ export class ListFilterModel {
|
|||
};
|
||||
break;
|
||||
}
|
||||
case "interactive": {
|
||||
result.interactive =
|
||||
(criterion as InteractiveCriterion).value === "true";
|
||||
break;
|
||||
}
|
||||
// no default
|
||||
}
|
||||
});
|
||||
|
|
|
|||
96
ui/v2.5/src/utils/interactive.ts
Normal file
96
ui/v2.5/src/utils/interactive.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import Handy from "thehandy";
|
||||
|
||||
interface IFunscript {
|
||||
actions: Array<IAction>;
|
||||
}
|
||||
|
||||
interface IAction {
|
||||
at: number;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
// Copied from handy-js-sdk under MIT license, with modifications. (It's not published to npm)
|
||||
// Converting to CSV first instead of uploading Funscripts will reduce uploaded file size.
|
||||
function convertFunscriptToCSV(funscript: IFunscript) {
|
||||
const lineTerminator = "\r\n";
|
||||
if (funscript?.actions?.length > 0) {
|
||||
return funscript.actions.reduce((prev: string, curr: IAction) => {
|
||||
return `${prev}${curr.at},${curr.pos}${lineTerminator}`;
|
||||
}, `#Created by stash.app ${new Date().toUTCString()}\n`);
|
||||
}
|
||||
throw new Error("Not a valid funscript");
|
||||
}
|
||||
|
||||
// Interactive currently uses the Handy API, but could be expanded to use buttplug.io
|
||||
// via buttplugio/buttplug-rs-ffi's WASM module.
|
||||
export class Interactive {
|
||||
private _connected: boolean;
|
||||
private _playing: boolean;
|
||||
private _handy: Handy;
|
||||
|
||||
constructor(handyKey: string) {
|
||||
this._handy = new Handy();
|
||||
this._handy.connectionKey = handyKey;
|
||||
this._connected = false;
|
||||
this._playing = false;
|
||||
}
|
||||
|
||||
get handyKey(): string {
|
||||
return this._handy.connectionKey;
|
||||
}
|
||||
|
||||
async uploadScript(funscriptPath: string) {
|
||||
if (!(this._handy.connectionKey && funscriptPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._handy.serverTimeOffset) {
|
||||
const cachedOffset = localStorage.getItem("serverTimeOffset");
|
||||
if (cachedOffset !== null) {
|
||||
this._handy.serverTimeOffset = parseInt(cachedOffset, 10);
|
||||
} else {
|
||||
// One time sync to get server time offset
|
||||
await this._handy.getServerTimeOffset();
|
||||
localStorage.setItem(
|
||||
"serverTimeOffset",
|
||||
this._handy.serverTimeOffset.toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const csv = await fetch(funscriptPath)
|
||||
.then((response) => response.json())
|
||||
.then((json) => convertFunscriptToCSV(json));
|
||||
const fileName = `${Math.round(Math.random() * 100000000)}.csv`;
|
||||
const csvFile = new File([csv], fileName);
|
||||
const tempURL = await this._handy
|
||||
.uploadCsv(csvFile)
|
||||
.then((response) => response.url);
|
||||
this._connected = await this._handy
|
||||
.syncPrepare(encodeURIComponent(tempURL), fileName, csvFile.size)
|
||||
.then((response) => response.connected);
|
||||
}
|
||||
|
||||
async play(position: number) {
|
||||
if (!this._connected) {
|
||||
return;
|
||||
}
|
||||
this._playing = await this._handy
|
||||
.syncPlay(true, Math.round(position * 1000))
|
||||
.then(() => true);
|
||||
}
|
||||
|
||||
async pause() {
|
||||
if (!this._connected) {
|
||||
return;
|
||||
}
|
||||
this._playing = await this._handy.syncPlay(false).then(() => false);
|
||||
}
|
||||
|
||||
async ensurePlaying(position: number) {
|
||||
if (this._playing) {
|
||||
return;
|
||||
}
|
||||
await this.play(position);
|
||||
}
|
||||
}
|
||||
|
|
@ -3011,11 +3011,6 @@
|
|||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@types/yup@^0.29.11":
|
||||
version "0.29.11"
|
||||
resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.29.11.tgz#d654a112973f5e004bf8438122bd7e56a8e5cd7e"
|
||||
integrity sha512-9cwk3c87qQKZrT251EDoibiYRILjCmxBvvcb4meofCmx1vdnNcR9gyildy5vOHASpOKMsn42CugxUvcwK5eu1g==
|
||||
|
||||
"@types/zen-observable@^0.8.0":
|
||||
version "0.8.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.2.tgz#808c9fa7e4517274ed555fa158f2de4b4f468e71"
|
||||
|
|
@ -14213,6 +14208,11 @@ text-table@0.2.0, text-table@^0.2.0:
|
|||
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
|
||||
integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
|
||||
|
||||
thehandy@^0.2.7:
|
||||
version "0.2.7"
|
||||
resolved "https://registry.yarnpkg.com/thehandy/-/thehandy-0.2.7.tgz#8677ada28f622eaa7c680b685c397899548fdba7"
|
||||
integrity sha512-Wo5sPWkoiRjAiK4EeZhOq1QRs4MVsl1Cc3tlPccrfsZLazXAUtUExkxzwA+N2MWJOavuJl5hoz/nV9ehF0yi7Q==
|
||||
|
||||
throat@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b"
|
||||
|
|
|
|||
Loading…
Reference in a new issue