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:
UnluckyChemical765
2021-05-23 20:34:28 -07:00
committed by GitHub
parent 33999d3e93
commit 547f6d79ad
32 changed files with 301 additions and 24 deletions

View File

@@ -53,6 +53,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
cssEnabled cssEnabled
language language
slideshowDelay slideshowDelay
handyKey
} }
fragment ConfigDLNAData on ConfigDLNAResult { fragment ConfigDLNAData on ConfigDLNAResult {

View File

@@ -11,6 +11,7 @@ fragment SlimSceneData on Scene {
organized organized
path path
phash phash
interactive
file { file {
size size
@@ -31,6 +32,7 @@ fragment SlimSceneData on Scene {
vtt vtt
chapters_vtt chapters_vtt
sprite sprite
funscript
} }
scene_markers { scene_markers {

View File

@@ -11,6 +11,7 @@ fragment SceneData on Scene {
organized organized
path path
phash phash
interactive
file { file {
size size
@@ -30,6 +31,7 @@ fragment SceneData on Scene {
webp webp
vtt vtt
chapters_vtt chapters_vtt
funscript
} }
scene_markers { scene_markers {

View File

@@ -190,6 +190,8 @@ input ConfigInterfaceInput {
language: String language: String
"""Slideshow Delay""" """Slideshow Delay"""
slideshowDelay: Int slideshowDelay: Int
"""Handy Connection Key"""
handyKey: String
} }
type ConfigInterfaceResult { type ConfigInterfaceResult {
@@ -214,6 +216,8 @@ type ConfigInterfaceResult {
language: String language: String
"""Slideshow Delay""" """Slideshow Delay"""
slideshowDelay: Int slideshowDelay: Int
"""Handy Connection Key"""
handyKey: String
} }
input ConfigDLNAInput { input ConfigDLNAInput {

View File

@@ -139,6 +139,8 @@ input SceneFilterType {
stash_id: StringCriterionInput stash_id: StringCriterionInput
"""Filter by url""" """Filter by url"""
url: StringCriterionInput url: StringCriterionInput
"""Filter by interactive"""
interactive: Boolean
} }
input MovieFilterType { input MovieFilterType {

View File

@@ -17,6 +17,7 @@ type ScenePathsType {
vtt: String # Resolver vtt: String # Resolver
chapters_vtt: String # Resolver chapters_vtt: String # Resolver
sprite: String # Resolver sprite: String # Resolver
funscript: String # Resolver
} }
type SceneMovie { type SceneMovie {
@@ -37,6 +38,7 @@ type Scene {
o_counter: Int o_counter: Int
path: String! path: String!
phash: String phash: String
interactive: Boolean!
file: SceneFileType! # Resolver file: SceneFileType! # Resolver
paths: ScenePathsType! # Resolver paths: ScenePathsType! # Resolver

View File

@@ -87,6 +87,8 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*models.S
vttPath := builder.GetSpriteVTTURL() vttPath := builder.GetSpriteVTTURL()
spritePath := builder.GetSpriteURL() spritePath := builder.GetSpriteURL()
chaptersVttPath := builder.GetChaptersVTTURL() chaptersVttPath := builder.GetChaptersVTTURL()
funscriptPath := builder.GetFunscriptURL()
return &models.ScenePathsType{ return &models.ScenePathsType{
Screenshot: &screenshotPath, Screenshot: &screenshotPath,
Preview: &previewPath, Preview: &previewPath,
@@ -95,6 +97,7 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*models.S
Vtt: &vttPath, Vtt: &vttPath,
ChaptersVtt: &chaptersVttPath, ChaptersVtt: &chaptersVttPath,
Sprite: &spritePath, Sprite: &spritePath,
Funscript: &funscriptPath,
}, nil }, nil
} }

View File

@@ -246,6 +246,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
c.Set(config.CSSEnabled, *input.CSSEnabled) c.Set(config.CSSEnabled, *input.CSSEnabled)
} }
if input.HandyKey != nil {
c.Set(config.HandyKey, *input.HandyKey)
}
if err := c.Write(); err != nil { if err := c.Write(); err != nil {
return makeConfigInterfaceResult(), err return makeConfigInterfaceResult(), err
} }

View File

@@ -95,6 +95,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
cssEnabled := config.GetCSSEnabled() cssEnabled := config.GetCSSEnabled()
language := config.GetLanguage() language := config.GetLanguage()
slideshowDelay := config.GetSlideshowDelay() slideshowDelay := config.GetSlideshowDelay()
handyKey := config.GetHandyKey()
return &models.ConfigInterfaceResult{ return &models.ConfigInterfaceResult{
MenuItems: menuItems, MenuItems: menuItems,
@@ -108,6 +109,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
CSSEnabled: &cssEnabled, CSSEnabled: &cssEnabled,
Language: &language, Language: &language,
SlideshowDelay: &slideshowDelay, SlideshowDelay: &slideshowDelay,
HandyKey: &handyKey,
} }
} }

View File

@@ -38,6 +38,7 @@ func (rs sceneRoutes) Routes() chi.Router {
r.Get("/preview", rs.Preview) r.Get("/preview", rs.Preview)
r.Get("/webp", rs.Webp) r.Get("/webp", rs.Webp)
r.Get("/vtt/chapter", rs.ChapterVtt) r.Get("/vtt/chapter", rs.ChapterVtt)
r.Get("/funscript", rs.Funscript)
r.Get("/scene_marker/{sceneMarkerId}/stream", rs.SceneMarkerStream) r.Get("/scene_marker/{sceneMarkerId}/stream", rs.SceneMarkerStream)
r.Get("/scene_marker/{sceneMarkerId}/preview", rs.SceneMarkerPreview) 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)) _, _ = 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) { func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene) scene := r.Context().Value(sceneKey).(*models.Scene)
w.Header().Set("Content-Type", "text/vtt") w.Header().Set("Content-Type", "text/vtt")

View File

@@ -58,3 +58,7 @@ func (b SceneURLBuilder) GetSceneMarkerStreamURL(sceneMarkerID int) string {
func (b SceneURLBuilder) GetSceneMarkerStreamPreviewURL(sceneMarkerID int) string { func (b SceneURLBuilder) GetSceneMarkerStreamPreviewURL(sceneMarkerID int) string {
return b.BaseURL + "/scene/" + b.SceneID + "/scene_marker/" + strconv.Itoa(sceneMarkerID) + "/preview" return b.BaseURL + "/scene/" + b.SceneID + "/scene_marker/" + strconv.Itoa(sceneMarkerID) + "/preview"
} }
func (b SceneURLBuilder) GetFunscriptURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/funscript"
}

View File

@@ -23,7 +23,7 @@ import (
var DB *sqlx.DB var DB *sqlx.DB
var WriteMu *sync.Mutex var WriteMu *sync.Mutex
var dbPath string var dbPath string
var appSchemaVersion uint = 22 var appSchemaVersion uint = 23
var databaseSchemaVersion uint var databaseSchemaVersion uint
var ( var (

View File

@@ -0,0 +1 @@
ALTER TABLE `scenes` ADD COLUMN `interactive` boolean not null default '0';

View File

@@ -1,17 +1,16 @@
package config package config
import ( import (
"errors"
"fmt" "fmt"
"io/ioutil"
"path/filepath"
"regexp"
"runtime" "runtime"
"strings" "strings"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"errors"
"io/ioutil"
"path/filepath"
"regexp"
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
@@ -123,6 +122,7 @@ const ShowStudioAsText = "show_studio_as_text"
const CSSEnabled = "cssEnabled" const CSSEnabled = "cssEnabled"
const WallPlayback = "wall_playback" const WallPlayback = "wall_playback"
const SlideshowDelay = "slideshow_delay" const SlideshowDelay = "slideshow_delay"
const HandyKey = "handy_key"
// DLNA options // DLNA options
const DLNAServerName = "dlna.server_name" const DLNAServerName = "dlna.server_name"
@@ -633,6 +633,10 @@ func (i *Instance) GetCSSEnabled() bool {
return viper.GetBool(CSSEnabled) return viper.GetBool(CSSEnabled)
} }
func (i *Instance) GetHandyKey() string {
return viper.GetString(HandyKey)
}
// GetDLNAServerName returns the visible name of the DLNA server. If empty, // GetDLNAServerName returns the visible name of the DLNA server. If empty,
// "stash" will be used. // "stash" will be used.
func (i *Instance) GetDLNAServerName() string { func (i *Instance) GetDLNAServerName() string {

View File

@@ -295,6 +295,12 @@ func (t *ScanTask) getFileModTime() (time.Time, error) {
return ret, nil 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 { func (t *ScanTask) isFileModified(fileModTime time.Time, modTime models.NullSQLiteTimestamp) bool {
return !modTime.Timestamp.Equal(fileModTime) return !modTime.Timestamp.Equal(fileModTime)
} }
@@ -376,6 +382,7 @@ func (t *ScanTask) scanScene() *models.Scene {
if err != nil { if err != nil {
return logError(err) return logError(err)
} }
interactive := t.getInteractive()
if s != nil { if s != nil {
// if file mod time is not set, set it now // 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 return nil
} }
@@ -549,8 +570,9 @@ func (t *ScanTask) scanScene() *models.Scene {
} else { } else {
logger.Infof("%s already exists. Updating path...", t.FilePath) logger.Infof("%s already exists. Updating path...", t.FilePath)
scenePartial := models.ScenePartial{ scenePartial := models.ScenePartial{
ID: s.ID, ID: s.ID,
Path: &t.FilePath, Path: &t.FilePath,
Interactive: &interactive,
} }
if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error {
_, err := r.Scene().Update(scenePartial) _, err := r.Scene().Update(scenePartial)
@@ -580,8 +602,9 @@ func (t *ScanTask) scanScene() *models.Scene {
Timestamp: fileModTime, Timestamp: fileModTime,
Valid: true, Valid: true,
}, },
CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
Interactive: interactive,
} }
if t.UseFileMetadata { if t.UseFileMetadata {

View File

@@ -32,6 +32,7 @@ type Scene struct {
Phash sql.NullInt64 `db:"phash,omitempty" json:"phash"` Phash sql.NullInt64 `db:"phash,omitempty" json:"phash"`
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_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 // 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"` Phash *sql.NullInt64 `db:"phash,omitempty" json:"phash"`
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"` CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_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, // GetTitle returns the title of the scene. If the Title field is empty,

View File

@@ -363,6 +363,7 @@ func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *fi
query.handleCriterionFunc(sceneIsMissingCriterionHandler(qb, sceneFilter.IsMissing)) query.handleCriterionFunc(sceneIsMissingCriterionHandler(qb, sceneFilter.IsMissing))
query.handleCriterionFunc(stringCriterionHandler(sceneFilter.URL, "scenes.url")) query.handleCriterionFunc(stringCriterionHandler(sceneFilter.URL, "scenes.url"))
query.handleCriterionFunc(stringCriterionHandler(sceneFilter.StashID, "scene_stash_ids.stash_id")) 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(sceneTagsCriterionHandler(qb, sceneFilter.Tags))
query.handleCriterionFunc(sceneTagCountCriterionHandler(qb, sceneFilter.TagCount)) query.handleCriterionFunc(sceneTagCountCriterionHandler(qb, sceneFilter.TagCount))

View File

@@ -296,3 +296,11 @@ func GetNameFromPath(path string, stripExtension bool) string {
} }
return fn 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"
}

View File

@@ -67,6 +67,7 @@
"sass": "^1.32.5", "sass": "^1.32.5",
"string.prototype.replaceall": "^1.0.4", "string.prototype.replaceall": "^1.0.4",
"subscriptions-transport-ws": "^0.9.18", "subscriptions-transport-ws": "^0.9.18",
"thehandy": "^0.2.7",
"universal-cookie": "^4.0.4", "universal-cookie": "^4.0.4",
"yup": "^0.32.9" "yup": "^0.32.9"
}, },

View File

@@ -1,4 +1,5 @@
### ✨ New Features ### ✨ 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 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)) * Added [DLNA server](/settings?tab=dlna). ([#1364](https://github.com/stashapp/stash/pull/1364))

View File

@@ -15,6 +15,7 @@ import SceneFilenameParser from "src/docs/en/SceneFilenameParser.md";
import KeyboardShortcuts from "src/docs/en/KeyboardShortcuts.md"; import KeyboardShortcuts from "src/docs/en/KeyboardShortcuts.md";
import Help from "src/docs/en/Help.md"; import Help from "src/docs/en/Help.md";
import Deduplication from "src/docs/en/Deduplication.md"; import Deduplication from "src/docs/en/Deduplication.md";
import Interactive from "src/docs/en/Interactive.md";
import { MarkdownPage } from "../Shared/MarkdownPage"; import { MarkdownPage } from "../Shared/MarkdownPage";
interface IManualProps { interface IManualProps {
@@ -92,6 +93,11 @@ export const Manual: React.FC<IManualProps> = ({
title: "Dupe Checker", title: "Dupe Checker",
content: Deduplication, content: Deduplication,
}, },
{
key: "Interactive.md",
title: "Interactivity",
content: Interactive,
},
{ {
key: "KeyboardShortcuts.md", key: "KeyboardShortcuts.md",
title: "Keyboard Shortcuts", title: "Keyboard Shortcuts",

View File

@@ -5,6 +5,7 @@ import * as GQL from "src/core/generated-graphql";
import { useConfiguration } from "src/core/StashService"; import { useConfiguration } from "src/core/StashService";
import { JWUtils } from "src/utils"; import { JWUtils } from "src/utils";
import { ScenePlayerScrubber } from "./ScenePlayerScrubber"; import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
import { Interactive } from "../../utils/interactive";
interface IScenePlayerProps { interface IScenePlayerProps {
className?: string; className?: string;
@@ -22,8 +23,8 @@ interface IScenePlayerState {
scrubberPosition: number; scrubberPosition: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
config: Record<string, any>; config: Record<string, any>;
interactiveClient: Interactive;
} }
export class ScenePlayerImpl extends React.Component< export class ScenePlayerImpl extends React.Component<
IScenePlayerProps, IScenePlayerProps,
IScenePlayerState IScenePlayerState
@@ -50,16 +51,15 @@ export class ScenePlayerImpl extends React.Component<
this.onScrubberSeek = this.onScrubberSeek.bind(this); this.onScrubberSeek = this.onScrubberSeek.bind(this);
this.onScrubberScrolled = this.onScrubberScrolled.bind(this); this.onScrubberScrolled = this.onScrubberScrolled.bind(this);
this.state = { this.state = {
scrubberPosition: 0, scrubberPosition: 0,
config: this.makeJWPlayerConfig(props.scene), config: this.makeJWPlayerConfig(props.scene),
interactiveClient: new Interactive(this.props.config?.handyKey || ""),
}; };
// Default back to Direct Streaming // Default back to Direct Streaming
localStorage.removeItem("jwplayer.qualityLabel"); localStorage.removeItem("jwplayer.qualityLabel");
} }
public UNSAFE_componentWillReceiveProps(props: IScenePlayerProps) { public UNSAFE_componentWillReceiveProps(props: IScenePlayerProps) {
if (props.scene !== this.props.scene) { if (props.scene !== this.props.scene) {
this.setState((state) => ({ this.setState((state) => ({
@@ -88,8 +88,11 @@ export class ScenePlayerImpl extends React.Component<
this.player.setPlaybackRate(1); this.player.setPlaybackRate(1);
} }
onPause() { onPause() {
if (this.player.getState().paused) this.player.play(); if (this.player.getState().paused) {
else this.player.pause(); this.player.play();
} else {
this.player.pause();
}
} }
private onReady() { private onReady() {
@@ -126,6 +129,24 @@ export class ScenePlayerImpl extends React.Component<
this.player.seek(this.props.timestamp); 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() { private onSeeked() {
@@ -140,6 +161,9 @@ export class ScenePlayerImpl extends React.Component<
if (difference > 1) { if (difference > 1) {
this.lastTime = position; this.lastTime = position;
this.setState({ scrubberPosition: position }); this.setState({ scrubberPosition: position });
if (this.props.scene.interactive) {
this.state.interactiveClient.ensurePlaying(position);
}
} }
} }

View File

@@ -6,7 +6,9 @@ import React, {
useRef, useRef,
useState, useState,
useCallback, useCallback,
useMemo,
} from "react"; } from "react";
import { debounce } from "lodash";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
import axios from "axios"; import axios from "axios";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -80,6 +82,10 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (
const velocity = useRef(0); const velocity = useRef(0);
const _position = 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 getPosition = useCallback(() => _position.current, []);
const setPosition = useCallback( const setPosition = useCallback(
(newPostion: number, shouldEmit: boolean = true) => { (newPostion: number, shouldEmit: boolean = true) => {
@@ -87,7 +93,7 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (
return; return;
} }
if (shouldEmit) { if (shouldEmit) {
props.onScrolled(); onScrolled();
} }
const midpointOffset = scrubberSliderEl.current.clientWidth / 2; const midpointOffset = scrubberSliderEl.current.clientWidth / 2;
@@ -108,7 +114,7 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (
scrubberSliderEl.current.clientWidth; scrubberSliderEl.current.clientWidth;
positionIndicatorEl.current.style.transform = `translateX(${indicatorPosition}px)`; positionIndicatorEl.current.style.transform = `translateX(${indicatorPosition}px)`;
}, },
[props] [onScrolled]
); );
const [spriteItems, setSpriteItems] = useState<ISceneSpriteItem[]>([]); const [spriteItems, setSpriteItems] = useState<ISceneSpriteItem[]>([]);
@@ -203,7 +209,7 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (
} }
if (seekSeconds) { if (seekSeconds) {
props.onSeek(seekSeconds); onSeek(seekSeconds);
} }
} else if (Math.abs(velocity.current) > 25) { } else if (Math.abs(velocity.current) > 25) {
const newPosition = getPosition() + velocity.current * 10; const newPosition = getPosition() + velocity.current * 10;

View File

@@ -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 ( return (
<div className="container scene-file-info"> <div className="container scene-file-info">
{renderOSHash()} {renderOSHash()}
@@ -239,6 +252,7 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
{renderPhash()} {renderPhash()}
{renderPath()} {renderPath()}
{renderStream()} {renderStream()}
{renderFunscript()}
{renderFileSize()} {renderFileSize()}
{renderDuration()} {renderDuration()}
{renderDimensions()} {renderDimensions()}

View File

@@ -34,6 +34,7 @@ export const SettingsInterfacePanel: React.FC = () => {
const [css, setCSS] = useState<string>(); const [css, setCSS] = useState<string>();
const [cssEnabled, setCSSEnabled] = useState<boolean>(false); const [cssEnabled, setCSSEnabled] = useState<boolean>(false);
const [language, setLanguage] = useState<string>("en"); const [language, setLanguage] = useState<string>("en");
const [handyKey, setHandyKey] = useState<string>();
const [updateInterfaceConfig] = useConfigureInterface({ const [updateInterfaceConfig] = useConfigureInterface({
menuItems: menuItemIds, menuItems: menuItemIds,
@@ -47,6 +48,7 @@ export const SettingsInterfacePanel: React.FC = () => {
cssEnabled, cssEnabled,
language, language,
slideshowDelay, slideshowDelay,
handyKey,
}); });
useEffect(() => { useEffect(() => {
@@ -62,6 +64,7 @@ export const SettingsInterfacePanel: React.FC = () => {
setCSSEnabled(iCfg?.cssEnabled ?? false); setCSSEnabled(iCfg?.cssEnabled ?? false);
setLanguage(iCfg?.language ?? "en-US"); setLanguage(iCfg?.language ?? "en-US");
setSlideshowDelay(iCfg?.slideshowDelay ?? 5000); setSlideshowDelay(iCfg?.slideshowDelay ?? 5000);
setHandyKey(iCfg?.handyKey ?? "");
}, [config]); }, [config]);
async function onSave() { async function onSave() {
@@ -235,6 +238,20 @@ export const SettingsInterfacePanel: React.FC = () => {
</Form.Text> </Form.Text>
</Form.Group> </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 /> <hr />
<Button variant="primary" onClick={() => onSave()}> <Button variant="primary" onClick={() => onSave()}>
Save Save

View 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.

View File

@@ -53,7 +53,8 @@ export type CriterionType =
| "performer_count" | "performer_count"
| "death_year" | "death_year"
| "url" | "url"
| "stash_id"; | "stash_id"
| "interactive";
type Option = string | number | IOptionType; type Option = string | number | IOptionType;
export type CriterionValue = string | number | ILabeledId[]; export type CriterionValue = string | number | ILabeledId[];
@@ -153,6 +154,8 @@ export abstract class Criterion {
return "URL"; return "URL";
case "stash_id": case "stash_id":
return "StashID"; return "StashID";
case "interactive":
return "Interactive";
} }
} }

View 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";
}

View File

@@ -28,6 +28,7 @@ import { TagsCriterion } from "./tags";
import { GenderCriterion } from "./gender"; import { GenderCriterion } from "./gender";
import { MoviesCriterion } from "./movies"; import { MoviesCriterion } from "./movies";
import { GalleriesCriterion } from "./galleries"; import { GalleriesCriterion } from "./galleries";
import { InteractiveCriterion } from "./interactive";
export function makeCriteria(type: CriterionType = "none") { export function makeCriteria(type: CriterionType = "none") {
switch (type) { switch (type) {
@@ -109,5 +110,7 @@ export function makeCriteria(type: CriterionType = "none") {
case "url": case "url":
case "stash_id": case "stash_id":
return new StringCriterion(type, type); return new StringCriterion(type, type);
case "interactive":
return new InteractiveCriterion();
} }
} }

View File

@@ -74,6 +74,10 @@ import { DisplayMode, FilterMode } from "./types";
import { GenderCriterionOption, GenderCriterion } from "./criteria/gender"; import { GenderCriterionOption, GenderCriterion } from "./criteria/gender";
import { MoviesCriterionOption, MoviesCriterion } from "./criteria/movies"; import { MoviesCriterionOption, MoviesCriterion } from "./criteria/movies";
import { GalleriesCriterion } from "./criteria/galleries"; import { GalleriesCriterion } from "./criteria/galleries";
import {
InteractiveCriterion,
InteractiveCriterionOption,
} from "./criteria/interactive";
interface IQueryParameters { interface IQueryParameters {
perPage?: string; perPage?: string;
@@ -136,6 +140,7 @@ export class ListFilterModel {
"performer_count", "performer_count",
"random", "random",
"movie_scene_number", "movie_scene_number",
"interactive",
]; ];
this.displayModeOptions = [ this.displayModeOptions = [
DisplayMode.Grid, DisplayMode.Grid,
@@ -162,6 +167,7 @@ export class ListFilterModel {
new MoviesCriterionOption(), new MoviesCriterionOption(),
ListFilterModel.createCriterionOption("url"), ListFilterModel.createCriterionOption("url"),
ListFilterModel.createCriterionOption("stash_id"), ListFilterModel.createCriterionOption("stash_id"),
new InteractiveCriterionOption(),
]; ];
break; break;
case FilterMode.Images: case FilterMode.Images:
@@ -671,6 +677,11 @@ export class ListFilterModel {
}; };
break; break;
} }
case "interactive": {
result.interactive =
(criterion as InteractiveCriterion).value === "true";
break;
}
// no default // no default
} }
}); });

View 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);
}
}

View File

@@ -3011,11 +3011,6 @@
dependencies: dependencies:
"@types/yargs-parser" "*" "@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": "@types/zen-observable@^0.8.0":
version "0.8.2" version "0.8.2"
resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.2.tgz#808c9fa7e4517274ed555fa158f2de4b4f468e71" 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" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= 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: throat@^5.0.0:
version "5.0.0" version "5.0.0"
resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b"