diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index a84ac97db..839a4eb27 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -36,7 +36,7 @@ "@types/react-select": "^4.0.8", "ansi-regex": "^5.0.1", "apollo-upload-client": "^14.1.3", - "axios": "0.21.2", + "axios": "0.24.0", "base64-blob": "^1.4.1", "bootstrap": "^4.6.0", "classnames": "^2.2.6", @@ -68,7 +68,7 @@ "sass": "^1.32.5", "string.prototype.replaceall": "^1.0.4", "subscriptions-transport-ws": "^0.9.18", - "thehandy": "^0.2.7", + "thehandy": "^1.0.3", "universal-cookie": "^4.0.4", "video.js": "^7.17.0", "videojs-landscape-fullscreen": "^11.33.0", diff --git a/ui/v2.5/src/components/Changelog/versions/v0140.md b/ui/v2.5/src/components/Changelog/versions/v0140.md index 783ee2a7a..05e53866f 100644 --- a/ui/v2.5/src/components/Changelog/versions/v0140.md +++ b/ui/v2.5/src/components/Changelog/versions/v0140.md @@ -2,6 +2,7 @@ * Add python location in System Settings for script scrapers and plugins. ([#2409](https://github.com/stashapp/stash/pull/2409)) ### 🎨 Improvements +* Added support for Handy APIv2. ([#2193](https://github.com/stashapp/stash/pull/2193)) * Hide tabs with no content in Performer, Studio and Tag pages. ([#2468](https://github.com/stashapp/stash/pull/2468)) * Added support for bulk editing most performer fields. ([#2467](https://github.com/stashapp/stash/pull/2467)) * Changed video player to videojs. ([#2100](https://github.com/stashapp/stash/pull/2100)) diff --git a/ui/v2.5/src/utils/interactive.ts b/ui/v2.5/src/utils/interactive.ts index 4dd01ec18..c38b25883 100644 --- a/ui/v2.5/src/utils/interactive.ts +++ b/ui/v2.5/src/utils/interactive.ts @@ -1,7 +1,14 @@ import Handy from "thehandy"; +import { + HandyMode, + HsspSetupResult, + CsvUploadResponse, +} from "thehandy/lib/types"; interface IFunscript { actions: Array; + inverted: boolean; + range: number; } interface IAction { @@ -9,18 +16,79 @@ interface IAction { 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. +// Utility function to convert one range of values to another +function convertRange( + value: number, + fromLow: number, + fromHigh: number, + toLow: number, + toHigh: number +) { + return ((value - fromLow) * (toHigh - toLow)) / (fromHigh - fromLow) + toLow; +} + +// Converting to CSV first instead of uploading Funscripts is required +// Reference for Funscript format: +// https://pkg.go.dev/github.com/funjack/launchcontrol/protocol/funscript 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}`; + var { pos } = curr; + // If it's inverted in the Funscript, we flip it because + // the Handy doesn't have inverted support + if (funscript.inverted === true) { + pos = convertRange(curr.pos, 0, 100, 100, 0); + } + // in APIv2; the Handy maintains it's own slide range + // (ref: https://staging.handyfeeling.com/api/handy/v2/docs/#/SLIDE ) + // so if a range is specified in the Funscript, we convert it to the + // full range and let the Handy's settings take precedence + if (funscript.range) { + pos = convertRange(curr.pos, 0, funscript.range, 0, 100); + } + return `${prev}${curr.at},${pos}${lineTerminator}`; }, `#Created by stash.app ${new Date().toUTCString()}\n`); } throw new Error("Not a valid funscript"); } +// copied from https://github.com/defucilis/thehandy/blob/main/src/HandyUtils.ts +// since HandyUtils is not exported. +// License is listed as MIT. No copyright notice is provided in original. +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +async function uploadCsv( + csv: File, + filename?: string +): Promise { + const url = "https://www.handyfeeling.com/api/sync/upload?local=true"; + if (!filename) filename = "script_" + new Date().valueOf() + ".csv"; + const formData = new FormData(); + formData.append("syncFile", csv, filename); + const response = await fetch(url, { + method: "post", + body: formData, + }); + const newUrl = await response.json(); + return newUrl; +} + // 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 { @@ -45,40 +113,34 @@ export class Interactive { 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() - ); - } - } + // Calibrates the latency between the browser client and the Handy server's + // This is done before a script upload to ensure a synchronized experience + await this._handy.getServerTimeOffset(); 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); + const tempURL = await uploadCsv(csvFile).then((response) => response.url); + + await this._handy.setMode(HandyMode.hssp); + this._connected = await this._handy - .syncPrepare(encodeURIComponent(tempURL), fileName, csvFile.size) - .then((response) => response.connected); + .setHsspSetup(tempURL) + .then((result) => result === HsspSetupResult.downloaded); } async play(position: number) { if (!this._connected) { return; } + this._playing = await this._handy - .syncPlay(true, Math.round(position * 1000 + this._scriptOffset)) + .setHsspPlay( + Math.round(position * 1000 + this._scriptOffset), + this._handy.estimatedServerTimeOffset + Date.now() // our guess of the Handy server's UNIX epoch time + ) .then(() => true); } @@ -86,7 +148,7 @@ export class Interactive { if (!this._connected) { return; } - this._playing = await this._handy.syncPlay(false).then(() => false); + this._playing = await this._handy.setHsspStop().then(() => false); } async ensurePlaying(position: number) { diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index 7b20a0bcf..35dc296f7 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -2005,12 +2005,12 @@ axe-core@^4.0.2: resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.1.3.tgz" integrity sha512-vwPpH4Aj4122EW38mxO/fxhGKtwWTMLDIJfZ1He0Edbtjcfna/R3YB67yVhezUMzqc3Jr3+Ii50KRntlENL4xQ== -axios@0.21.2: - version "0.21.2" - resolved "https://registry.npmjs.org/axios/-/axios-0.21.2.tgz" - integrity sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg== +axios@0.24.0: + version "0.24.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.24.0.tgz#804e6fa1e4b9c5288501dd9dff56a7a0940d20d6" + integrity sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA== dependencies: - follow-redirects "^1.14.0" + follow-redirects "^1.14.4" axobject-query@^2.2.0: version "2.2.0" @@ -3556,10 +3556,10 @@ flexbin@^0.2.0: resolved "https://registry.npmjs.org/flexbin/-/flexbin-0.2.0.tgz" integrity sha1-ASYwbT1ZX8t9/LhxSbnJWZ/49Ok= -follow-redirects@^1.14.0: - version "1.14.8" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc" - integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA== +follow-redirects@^1.14.4: + version "1.14.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.6.tgz#8cfb281bbc035b3c067d6cd975b0f6ade6e855cd" + integrity sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A== forever-agent@~0.6.1: version "0.6.1" @@ -7499,10 +7499,10 @@ text-table@^0.2.0: resolved "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz" integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= -thehandy@^0.2.7: - version "0.2.7" - resolved "https://registry.npmjs.org/thehandy/-/thehandy-0.2.7.tgz" - integrity sha512-Wo5sPWkoiRjAiK4EeZhOq1QRs4MVsl1Cc3tlPccrfsZLazXAUtUExkxzwA+N2MWJOavuJl5hoz/nV9ehF0yi7Q== +thehandy@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/thehandy/-/thehandy-1.0.3.tgz#51c5e9bae5932a6e5c563203711d78610b99d402" + integrity sha512-zuuyWKBx/jqku9+MZkdkoK2oLM2mS8byWVR/vkQYq/ygAT6gPAXwiT94rfGuqv+1BLmsyJxm69nhVIzOZjfyIg== through@^2.3.6: version "2.3.8"