mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03: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:
committed by
GitHub
parent
33999d3e93
commit
547f6d79ad
@@ -53,6 +53,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
|
|||||||
cssEnabled
|
cssEnabled
|
||||||
language
|
language
|
||||||
slideshowDelay
|
slideshowDelay
|
||||||
|
handyKey
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment ConfigDLNAData on ConfigDLNAResult {
|
fragment ConfigDLNAData on ConfigDLNAResult {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
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
|
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,6 +572,7 @@ func (t *ScanTask) scanScene() *models.Scene {
|
|||||||
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)
|
||||||
@@ -582,6 +604,7 @@ func (t *ScanTask) scanScene() *models.Scene {
|
|||||||
},
|
},
|
||||||
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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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"
|
| "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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 { 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
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:
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user