From 547f6d79ad8c3bd5877d01a62c3972cbd2e859e5 Mon Sep 17 00:00:00 2001 From: UnluckyChemical765 <83972682+UnluckyChemical765@users.noreply.github.com> Date: Sun, 23 May 2021 20:34:28 -0700 Subject: [PATCH] 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. --- graphql/documents/data/config.graphql | 1 + graphql/documents/data/scene-slim.graphql | 2 + graphql/documents/data/scene.graphql | 2 + graphql/schema/types/config.graphql | 4 + graphql/schema/types/filters.graphql | 2 + graphql/schema/types/scene.graphql | 2 + pkg/api/resolver_model_scene.go | 3 + pkg/api/resolver_mutation_configure.go | 4 + pkg/api/resolver_query_configuration.go | 2 + pkg/api/routes_scene.go | 7 ++ pkg/api/urlbuilders/scene.go | 4 + pkg/database/database.go | 2 +- .../migrations/23_scenes_interactive.up.sql | 1 + pkg/manager/config/config.go | 14 ++- pkg/manager/task_scan.go | 31 +++++- pkg/models/model_scene.go | 2 + pkg/sqlite/scene.go | 1 + pkg/utils/file.go | 8 ++ ui/v2.5/package.json | 1 + .../src/components/Changelog/versions/v080.md | 1 + ui/v2.5/src/components/Help/Manual.tsx | 6 ++ .../components/ScenePlayer/ScenePlayer.tsx | 34 ++++++- .../ScenePlayer/ScenePlayerScrubber.tsx | 12 ++- .../SceneDetails/SceneFileInfoPanel.tsx | 14 +++ .../SettingsInterfacePanel.tsx | 17 ++++ ui/v2.5/src/docs/en/Interactive.md | 7 ++ .../models/list-filter/criteria/criterion.ts | 5 +- .../list-filter/criteria/interactive.ts | 16 ++++ .../src/models/list-filter/criteria/utils.ts | 3 + ui/v2.5/src/models/list-filter/filter.ts | 11 +++ ui/v2.5/src/utils/interactive.ts | 96 +++++++++++++++++++ ui/v2.5/yarn.lock | 10 +- 32 files changed, 301 insertions(+), 24 deletions(-) create mode 100644 pkg/database/migrations/23_scenes_interactive.up.sql create mode 100644 ui/v2.5/src/docs/en/Interactive.md create mode 100644 ui/v2.5/src/models/list-filter/criteria/interactive.ts create mode 100644 ui/v2.5/src/utils/interactive.ts diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 6e66d58c5..b18fb7c91 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -53,6 +53,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult { cssEnabled language slideshowDelay + handyKey } fragment ConfigDLNAData on ConfigDLNAResult { diff --git a/graphql/documents/data/scene-slim.graphql b/graphql/documents/data/scene-slim.graphql index 4aacf27e4..b7040ce4e 100644 --- a/graphql/documents/data/scene-slim.graphql +++ b/graphql/documents/data/scene-slim.graphql @@ -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 { diff --git a/graphql/documents/data/scene.graphql b/graphql/documents/data/scene.graphql index 83077895c..9d3299aa9 100644 --- a/graphql/documents/data/scene.graphql +++ b/graphql/documents/data/scene.graphql @@ -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 { diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index ab8ac69f8..f5b039b04 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -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 { diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 70dc77173..e0f0c0e90 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -139,6 +139,8 @@ input SceneFilterType { stash_id: StringCriterionInput """Filter by url""" url: StringCriterionInput + """Filter by interactive""" + interactive: Boolean } input MovieFilterType { diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index 84d2fdf79..1d8d12ba8 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -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 diff --git a/pkg/api/resolver_model_scene.go b/pkg/api/resolver_model_scene.go index f657d8371..986e40eda 100644 --- a/pkg/api/resolver_model_scene.go +++ b/pkg/api/resolver_model_scene.go @@ -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 } diff --git a/pkg/api/resolver_mutation_configure.go b/pkg/api/resolver_mutation_configure.go index 2a4c6743d..681f04a61 100644 --- a/pkg/api/resolver_mutation_configure.go +++ b/pkg/api/resolver_mutation_configure.go @@ -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 } diff --git a/pkg/api/resolver_query_configuration.go b/pkg/api/resolver_query_configuration.go index 1063dc086..7c450e9f9 100644 --- a/pkg/api/resolver_query_configuration.go +++ b/pkg/api/resolver_query_configuration.go @@ -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, } } diff --git a/pkg/api/routes_scene.go b/pkg/api/routes_scene.go index 224da7ce4..d7bb1d888 100644 --- a/pkg/api/routes_scene.go +++ b/pkg/api/routes_scene.go @@ -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") diff --git a/pkg/api/urlbuilders/scene.go b/pkg/api/urlbuilders/scene.go index 9a31e504f..e9766da9e 100644 --- a/pkg/api/urlbuilders/scene.go +++ b/pkg/api/urlbuilders/scene.go @@ -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" +} diff --git a/pkg/database/database.go b/pkg/database/database.go index e3ddff607..6d6af4039 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -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 ( diff --git a/pkg/database/migrations/23_scenes_interactive.up.sql b/pkg/database/migrations/23_scenes_interactive.up.sql new file mode 100644 index 000000000..857d5660b --- /dev/null +++ b/pkg/database/migrations/23_scenes_interactive.up.sql @@ -0,0 +1 @@ +ALTER TABLE `scenes` ADD COLUMN `interactive` boolean not null default '0'; diff --git a/pkg/manager/config/config.go b/pkg/manager/config/config.go index 2472bfc24..61aba72aa 100644 --- a/pkg/manager/config/config.go +++ b/pkg/manager/config/config.go @@ -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 { diff --git a/pkg/manager/task_scan.go b/pkg/manager/task_scan.go index 568589e9f..963f9ab48 100644 --- a/pkg/manager/task_scan.go +++ b/pkg/manager/task_scan.go @@ -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 { diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go index 514ef8cbf..2adb0a274 100644 --- a/pkg/models/model_scene.go +++ b/pkg/models/model_scene.go @@ -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, diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index c3780a573..802d55d12 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -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)) diff --git a/pkg/utils/file.go b/pkg/utils/file.go index 09e134b4e..00189a704 100644 --- a/pkg/utils/file.go +++ b/pkg/utils/file.go @@ -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" +} diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index 765216f10..c36fd1989 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -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" }, diff --git a/ui/v2.5/src/components/Changelog/versions/v080.md b/ui/v2.5/src/components/Changelog/versions/v080.md index 325a76ba2..899cfa56e 100644 --- a/ui/v2.5/src/components/Changelog/versions/v080.md +++ b/ui/v2.5/src/components/Changelog/versions/v080.md @@ -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)) diff --git a/ui/v2.5/src/components/Help/Manual.tsx b/ui/v2.5/src/components/Help/Manual.tsx index 89ed6637b..1f0f1213b 100644 --- a/ui/v2.5/src/components/Help/Manual.tsx +++ b/ui/v2.5/src/components/Help/Manual.tsx @@ -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 = ({ title: "Dupe Checker", content: Deduplication, }, + { + key: "Interactive.md", + title: "Interactivity", + content: Interactive, + }, { key: "KeyboardShortcuts.md", title: "Keyboard Shortcuts", diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 054eb3f2e..62460add5 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -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; + 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); + } } } diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx index bfdb318d3..fb08304fd 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx @@ -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 = ( 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 = ( return; } if (shouldEmit) { - props.onScrolled(); + onScrolled(); } const midpointOffset = scrubberSliderEl.current.clientWidth / 2; @@ -108,7 +114,7 @@ export const ScenePlayerScrubber: React.FC = ( scrubberSliderEl.current.clientWidth; positionIndicatorEl.current.style.transform = `translateX(${indicatorPosition}px)`; }, - [props] + [onScrolled] ); const [spriteItems, setSpriteItems] = useState([]); @@ -203,7 +209,7 @@ export const ScenePlayerScrubber: React.FC = ( } if (seekSeconds) { - props.onSeek(seekSeconds); + onSeek(seekSeconds); } } else if (Math.abs(velocity.current) > 25) { const newPosition = getPosition() + velocity.current * 10; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx index 343cd77a8..401cfc572 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx @@ -232,6 +232,19 @@ export const SceneFileInfoPanel: React.FC = ( } } + function renderFunscript() { + if (props.scene.interactive) { + return ( +
+ Funscript + + + {" "} +
+ ); + } + } + return (
{renderOSHash()} @@ -239,6 +252,7 @@ export const SceneFileInfoPanel: React.FC = ( {renderPhash()} {renderPath()} {renderStream()} + {renderFunscript()} {renderFileSize()} {renderDuration()} {renderDimensions()} diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 809d6f173..012af931e 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -34,6 +34,7 @@ export const SettingsInterfacePanel: React.FC = () => { const [css, setCSS] = useState(); const [cssEnabled, setCSSEnabled] = useState(false); const [language, setLanguage] = useState("en"); + const [handyKey, setHandyKey] = useState(); 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 = () => { + +
Handy Connection Key
+ ) => { + setHandyKey(e.currentTarget.value); + }} + /> + + Handy connection key to use for interactive scenes. + +
+