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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 301 additions and 24 deletions

View file

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

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

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

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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,
}
}

View file

@ -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")

View file

@ -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"
}

View file

@ -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 (

View file

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

View file

@ -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 {

View file

@ -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 {

View file

@ -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,

View file

@ -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))

View file

@ -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"
}

View file

@ -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"
},

View file

@ -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))

View file

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

View file

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

View file

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

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

View file

@ -34,6 +34,7 @@ export const SettingsInterfacePanel: React.FC = () => {
const [css, setCSS] = useState<string>();
const [cssEnabled, setCSSEnabled] = useState<boolean>(false);
const [language, setLanguage] = useState<string>("en");
const [handyKey, setHandyKey] = useState<string>();
const [updateInterfaceConfig] = useConfigureInterface({
menuItems: menuItemIds,
@ -47,6 +48,7 @@ export const SettingsInterfacePanel: React.FC = () => {
cssEnabled,
language,
slideshowDelay,
handyKey,
});
useEffect(() => {
@ -62,6 +64,7 @@ export const SettingsInterfacePanel: React.FC = () => {
setCSSEnabled(iCfg?.cssEnabled ?? false);
setLanguage(iCfg?.language ?? "en-US");
setSlideshowDelay(iCfg?.slideshowDelay ?? 5000);
setHandyKey(iCfg?.handyKey ?? "");
}, [config]);
async function onSave() {
@ -235,6 +238,20 @@ export const SettingsInterfacePanel: React.FC = () => {
</Form.Text>
</Form.Group>
<Form.Group>
<h5>Handy Connection Key</h5>
<Form.Control
className="col col-sm-6 text-input"
value={handyKey}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setHandyKey(e.currentTarget.value);
}}
/>
<Form.Text className="text-muted">
Handy connection key to use for interactive scenes.
</Form.Text>
</Form.Group>
<hr />
<Button variant="primary" onClick={() => onSave()}>
Save

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

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 { MoviesCriterion } from "./movies";
import { GalleriesCriterion } from "./galleries";
import { InteractiveCriterion } from "./interactive";
export function makeCriteria(type: CriterionType = "none") {
switch (type) {
@ -109,5 +110,7 @@ export function makeCriteria(type: CriterionType = "none") {
case "url":
case "stash_id":
return new StringCriterion(type, type);
case "interactive":
return new InteractiveCriterion();
}
}

View file

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

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:
"@types/yargs-parser" "*"
"@types/yup@^0.29.11":
version "0.29.11"
resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.29.11.tgz#d654a112973f5e004bf8438122bd7e56a8e5cd7e"
integrity sha512-9cwk3c87qQKZrT251EDoibiYRILjCmxBvvcb4meofCmx1vdnNcR9gyildy5vOHASpOKMsn42CugxUvcwK5eu1g==
"@types/zen-observable@^0.8.0":
version "0.8.2"
resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.2.tgz#808c9fa7e4517274ed555fa158f2de4b4f468e71"
@ -14213,6 +14208,11 @@ text-table@0.2.0, text-table@^0.2.0:
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
thehandy@^0.2.7:
version "0.2.7"
resolved "https://registry.yarnpkg.com/thehandy/-/thehandy-0.2.7.tgz#8677ada28f622eaa7c680b685c397899548fdba7"
integrity sha512-Wo5sPWkoiRjAiK4EeZhOq1QRs4MVsl1Cc3tlPccrfsZLazXAUtUExkxzwA+N2MWJOavuJl5hoz/nV9ehF0yi7Q==
throat@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b"