diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index 001e7fb603..4c9880a203 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -50,6 +50,7 @@ "graphql-tag": "^2.12.6", "graphql-ws": "^5.14.3", "i18n-iso-countries": "^7.5.0", + "ive-connect": "^1.1.0", "localforage": "^1.10.0", "lodash-es": "^4.17.23", "moment": "^2.30.1", @@ -72,7 +73,6 @@ "resize-observer-polyfill": "^1.5.1", "slick-carousel": "^1.8.1", "string.prototype.replaceall": "^1.0.7", - "thehandy": "^1.0.3", "ua-parser-js": "^1.0.34", "universal-cookie": "^4.0.4", "video.js": "^7.21.3", diff --git a/ui/v2.5/pnpm-lock.yaml b/ui/v2.5/pnpm-lock.yaml index 46dcec4d86..433490b718 100644 --- a/ui/v2.5/pnpm-lock.yaml +++ b/ui/v2.5/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: i18n-iso-countries: specifier: ^7.5.0 version: 7.14.0 + ive-connect: + specifier: ^1.1.0 + version: 1.1.0 localforage: specifier: ^1.10.0 version: 1.10.0 @@ -158,9 +161,6 @@ importers: string.prototype.replaceall: specifier: ^1.0.7 version: 1.0.11 - thehandy: - specifier: ^1.0.3 - version: 1.1.0 ua-parser-js: specifier: ^1.0.34 version: 1.0.41 @@ -2182,6 +2182,9 @@ packages: resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + '@xsense/autoblow-sdk@2.1.1': + resolution: {integrity: sha512-l0zGxuM/IFLQsS9Gi5BKHYwky6k2kvL5MxAqavxRajWf3ALwnXzij0T72T9uwSoRDUZIjjX0tbgSgwfrxQ0pnw==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2981,6 +2984,14 @@ packages: event-target-polyfill@0.0.4: resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@4.1.0: + resolution: {integrity: sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==} + engines: {node: '>=20.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -3598,6 +3609,10 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + ive-connect@1.1.0: + resolution: {integrity: sha512-LazXseSSWoBxHehpowbCt3LSMhUvMfHDrMr6K6lISlZ6sw78R0EZIm5L0mjwkelL3IPBZsNvHwvbn6ukG6Gs0Q==} + engines: {node: '>=22.0.0'} + jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true @@ -4855,9 +4870,6 @@ packages: text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - thehandy@1.1.0: - resolution: {integrity: sha512-ZifUw47kq6cKNiKLNgrnVPkBFbG+yR6tScgWy2INDnGT4XePhjRaQNni67rWn52nAOkotq9VyaK20OZoorHqTA==} - three@0.93.0: resolution: {integrity: sha512-Ys9+UBBsd6FxTZZl4BH7B4b2F+B2uR0cOwY7OQ/aCzU/VgO4Wmmr1LbWPH1fsTvSVik9KAuwxwOHlSC4IMGOLA==} @@ -7661,6 +7673,10 @@ snapshots: '@xmldom/xmldom@0.8.11': {} + '@xsense/autoblow-sdk@2.1.1': + dependencies: + eventsource: 4.1.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -8682,6 +8698,12 @@ snapshots: event-target-polyfill@0.0.4: {} + eventsource-parser@3.0.6: {} + + eventsource@4.1.0: + dependencies: + eventsource-parser: 3.0.6 + extend@3.0.2: {} extract-files@13.0.0: @@ -9351,6 +9373,10 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + ive-connect@1.1.0: + dependencies: + '@xsense/autoblow-sdk': 2.1.1 + jiti@1.21.7: {} jiti@2.6.1: {} @@ -10866,8 +10892,6 @@ snapshots: text-table@0.2.0: {} - thehandy@1.1.0: {} - three@0.93.0: {} throttle-debounce@5.0.2: {} diff --git a/ui/v2.5/src/hooks/Interactive/context.tsx b/ui/v2.5/src/hooks/Interactive/context.tsx index ccdc948b48..a1a39feccf 100644 --- a/ui/v2.5/src/hooks/Interactive/context.tsx +++ b/ui/v2.5/src/hooks/Interactive/context.tsx @@ -125,7 +125,7 @@ export const InteractiveProvider: React.FC = ({ children }) => { if (!config?.serverOffset || shouldResync) { setState(ConnectionState.Syncing); - const offset = await interactive.sync(); + const offset = interactive.sync(); setConfig({ serverOffset: offset, lastSyncTime: Date.now() }); } @@ -196,7 +196,7 @@ export const InteractiveProvider: React.FC = ({ children }) => { } setState(ConnectionState.Syncing); - const offset = await interactive.sync(); + const offset = interactive.sync(); setConfig({ serverOffset: offset, lastSyncTime: Date.now() }); setState(ConnectionState.Ready); }, [interactive, state, setConfig, initialised]); diff --git a/ui/v2.5/src/hooks/Interactive/interactive.ts b/ui/v2.5/src/hooks/Interactive/interactive.ts index 2b1227243c..b55503545a 100644 --- a/ui/v2.5/src/hooks/Interactive/interactive.ts +++ b/ui/v2.5/src/hooks/Interactive/interactive.ts @@ -1,224 +1,106 @@ -import Handy from "thehandy"; -import { - HandyMode, - HsspSetupResult, - CsvUploadResponse, - HandyFirmwareStatus, -} from "thehandy/lib/types"; -import { IDeviceSettings } from "./utils"; - -interface IFunscript { - actions: Array; - inverted: boolean; - range: number; -} - -interface IAction { - at: number; - pos: number; -} - -// 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) => { - 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; -} +import { DeviceManager, HandyDevice, loadScript } from "ive-connect"; +import type { IDeviceSettings } from "./utils"; // 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 { - _connected: boolean; _playing: boolean; _scriptOffset: number; - _handy: Handy; - _useStashHostedFunscript: boolean; + _manager = new DeviceManager(); + _handyDevice: HandyDevice; + _connectionKey: string; constructor(handyKey: string, scriptOffset: number) { - this._handy = new Handy(); - this._handy.connectionKey = handyKey; this._scriptOffset = scriptOffset; - this._useStashHostedFunscript = false; - this._connected = false; + this._connectionKey = handyKey; + this._handyDevice = new HandyDevice({ + connectionKey: this._connectionKey, + }); + this._handyDevice.updateConfig({ + offset: this._scriptOffset, + }); this._playing = false; } + async updateConfig() { + this._handyDevice.updateConfig({ + connectionKey: this._connectionKey, + offset: this._scriptOffset, + }); + } + get connected() { - return this._connected; + return this._handyDevice.isConnected; } get playing() { - return this._playing; + return this._handyDevice.isPlaying; } async connect() { - const connected = await this._handy.getConnected(); + const connected = await this._handyDevice.connect({ + offset: this._scriptOffset, + }); if (!connected) { throw new Error("Handy not connected"); } - - // check the firmware and make sure it's compatible - const info = await this._handy.getInfo(); - if (info.fwStatus === HandyFirmwareStatus.updateRequired) { - throw new Error("Handy firmware update required"); - } } set handyKey(key: string) { - this._handy.connectionKey = key; - } - - get handyKey(): string { - return this._handy.connectionKey; - } - - set useStashHostedFunscript(useStashHostedFunscript: boolean) { - this._useStashHostedFunscript = useStashHostedFunscript; - } - - get useStashHostedFunscript(): boolean { - return this._useStashHostedFunscript; + this._connectionKey = key; + this.updateConfig(); } set scriptOffset(offset: number) { this._scriptOffset = offset; + this.updateConfig(); } async uploadScript(funscriptPath: string, apiKey?: string) { - if (!(this._handy.connectionKey && funscriptPath)) { - return; + // append apikey if necessary + var funscriptURL = funscriptPath; + if (typeof apiKey !== "undefined" && apiKey !== "") { + const url = new URL(funscriptPath); + url.searchParams.append("apikey", apiKey); + funscriptPath = url.toString(); } - var funscriptUrl; - - if (this._useStashHostedFunscript) { - funscriptUrl = funscriptPath.replace("/funscript", "/interactive_csv"); - if (typeof apiKey !== "undefined" && apiKey !== "") { - var url = new URL(funscriptUrl); - url.searchParams.append("apikey", apiKey); - funscriptUrl = url.toString(); - } - } else { - 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); - - funscriptUrl = await uploadCsv(csvFile).then((response) => response.url); + const result = await loadScript({ + type: "funscript", + url: funscriptURL, + }); + if (result.error) { + throw new Error(result.error); } - - await this._handy.setMode(HandyMode.hssp); - - this._connected = await this._handy - .setHsspSetup(funscriptUrl) - .then((result) => result === HsspSetupResult.downloaded); - - // for some reason we need to call getStatus after setup to ensure proper state - // see https://github.com/defucilis/thehandy/issues/3 - await this._handy.getStatus(); - } - - async sync() { - return this._handy.getServerTimeOffset(); } - setServerTimeOffset(offset: number) { - this._handy.estimatedServerTimeOffset = offset; + sync() { + // only function that handles offset is updateConfig + return this._handyDevice.api.getServerTimeOffset(); } async configure(config: Partial) { this._scriptOffset = config.scriptOffset ?? this._scriptOffset; this.handyKey = config.connectionKey ?? this.handyKey; - this._handy.estimatedServerTimeOffset = - config.estimatedServerTimeOffset ?? this._handy.estimatedServerTimeOffset; - this.useStashHostedFunscript = - config.useStashHostedFunscript ?? this.useStashHostedFunscript; } - async play(position: number) { - if (!this._connected) { + async play(position: number, loop: boolean = false) { + if (!this.connected) { return; } - this._playing = await this._handy - .setHsspPlay( - Math.round(position * 1000 + this._scriptOffset), - this._handy.estimatedServerTimeOffset + Date.now() // our guess of the Handy server's UNIX epoch time - ) - .then(() => true); + this._playing = await this._handyDevice.play( + Math.round(position * 1000 + this._scriptOffset), + 1.0, // playback rate + loop + ); } async pause() { - if (!this._connected) { + if (!this.connected) { return; } - this._playing = await this._handy.setHsspStop().then(() => false); + // returns boolean about success, not playing state + this._playing = await this._handyDevice.stop().then((res) => !!res); } async ensurePlaying(position: number) { @@ -229,9 +111,9 @@ export class Interactive { } async setLooping(looping: boolean) { - if (!this._connected) { + if (!this.connected) { return; } - this._handy.setHsspLoop(looping); + this._handyDevice.hspSetLoop(looping); } } diff --git a/ui/v2.5/src/hooks/Interactive/utils.ts b/ui/v2.5/src/hooks/Interactive/utils.ts index c1d066e869..cccda15638 100644 --- a/ui/v2.5/src/hooks/Interactive/utils.ts +++ b/ui/v2.5/src/hooks/Interactive/utils.ts @@ -27,7 +27,7 @@ export interface IInteractiveClient { connect(): Promise; handyKey: string; uploadScript: (funscriptPath: string, apiKey?: string) => Promise; - sync(): Promise; + sync(): number; configure(config: Partial): Promise; play(position: number): Promise; pause(): Promise;