UNPKG

ive-connect

Version:

A universal haptic device control library for interactive experiences

502 lines (501 loc) 20.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.HandyDevice = void 0; /** * Handy Device Implementation */ const device_interface_1 = require("../../core/device-interface"); const events_1 = require("../../core/events"); const handy_api_1 = require("./handy-api"); /** * Default Handy configuration */ const DEFAULT_CONFIG = { id: "handy", name: "Handy", connectionKey: "", enabled: true, offset: 0, stroke: { min: 0, max: 1, }, }; /** * Handy device implementation */ class HandyDevice extends events_1.EventEmitter { /** * Create a new Handy device instance * @param config Optional configuration */ constructor(config) { super(); this._connectionState = device_interface_1.ConnectionState.DISCONNECTED; this._deviceInfo = null; this._isPlaying = false; this._eventSource = null; this.id = "handy"; this.name = "Handy"; this.type = "handy"; this.capabilities = [ device_interface_1.DeviceCapability.LINEAR, device_interface_1.DeviceCapability.STROKE, ]; this._config = { ...DEFAULT_CONFIG }; // Set up configuration if (config === null || config === void 0 ? void 0 : config.connectionKey) { this._config.connectionKey = config.connectionKey; } // Create the API client this._api = (0, handy_api_1.createHandyApi)((config === null || config === void 0 ? void 0 : config.baseV3Url) || "https://www.handyfeeling.com/api/handy-rest/v3", (config === null || config === void 0 ? void 0 : config.baseV2Url) || "https://www.handyfeeling.com/api/hosting/v2", (config === null || config === void 0 ? void 0 : config.applicationId) || "12345", this._config.connectionKey); } /** * Get device connection state */ get isConnected() { return this._connectionState === device_interface_1.ConnectionState.CONNECTED; } /** * Get device playback state */ get isPlaying() { return this._isPlaying; } /** * Connect to the device * @param config Optional configuration override */ async connect(config) { try { // Update config if provided if (config) { this.updateConfig(config); } // Validate connection key if (!this._config.connectionKey || this._config.connectionKey.length < 5) { this.emit("error", "Connection key must be at least 5 characters"); return false; } // Update connection state this._connectionState = device_interface_1.ConnectionState.CONNECTING; this.emit("connectionStateChanged", this._connectionState); // Synchronize time await this._api.syncServerTime(); // Create event source for server-sent events this._eventSource = this._api.createEventSource(); // Set up event handlers this._setupEventHandlers(); // Get initial device info const isConnected = await this._api.isConnected(); if (isConnected) { this._deviceInfo = await this._api.getDeviceInfo(); this._connectionState = device_interface_1.ConnectionState.CONNECTED; this.emit("connectionStateChanged", this._connectionState); this.emit("connected", this._deviceInfo); // Get device settings after connection await this._loadDeviceSettings(); return true; } else { this._connectionState = device_interface_1.ConnectionState.DISCONNECTED; this.emit("connectionStateChanged", this._connectionState); this.emit("error", "Failed to connect to device"); return false; } } catch (error) { console.error("Handy: Error connecting to device:", error); this._connectionState = device_interface_1.ConnectionState.DISCONNECTED; this.emit("connectionStateChanged", this._connectionState); this.emit("error", `Connection error: ${error instanceof Error ? error.message : String(error)}`); return false; } } /** * Disconnect from the device */ async disconnect() { try { // Stop playback if active if (this._isPlaying) { await this.stop(); } // Close event source if (this._eventSource) { this._eventSource.close(); this._eventSource = null; } // Update state immediately this._connectionState = device_interface_1.ConnectionState.DISCONNECTED; this._deviceInfo = null; this._isPlaying = false; // Emit events this.emit("connectionStateChanged", this._connectionState); this.emit("disconnected"); // Return true regardless of what happens with the API return true; } catch (error) { console.error("Handy: Error disconnecting device:", error); // Set disconnected state even in case of error this._connectionState = device_interface_1.ConnectionState.DISCONNECTED; this._deviceInfo = null; this._isPlaying = false; // Emit events this.emit("connectionStateChanged", this._connectionState); this.emit("disconnected"); return true; // Return true anyway for better UX } } /** * Get current device configuration */ getConfig() { return { ...this._config }; } /** * Update device configuration * @param config Partial configuration to update */ async updateConfig(config) { // Update local config if (config.connectionKey !== undefined) { this._config.connectionKey = config.connectionKey; this._api.setConnectionKey(config.connectionKey); } // Update offset if connected if (config.offset !== undefined && this.isConnected) { this._config.offset = config.offset; await this._api.setOffset(config.offset); } else if (config.offset !== undefined) { this._config.offset = config.offset; } // Update stroke settings if connected if (config.stroke !== undefined && this.isConnected) { this._config.stroke = { ...this._config.stroke, ...config.stroke }; await this._api.setStrokeSettings(this._config.stroke); } else if (config.stroke !== undefined) { this._config.stroke = { ...this._config.stroke, ...config.stroke }; } // Update other fields if present if (config.name !== undefined) { this._config.name = config.name; } if (config.enabled !== undefined) { this._config.enabled = config.enabled; } // Emit configuration changed event this.emit("configChanged", this._config); return true; } /** * Load a script for playback * @param scriptData Script data to load */ async loadScript(scriptData, options = { invertScript: false }) { if (!this.isConnected) { this.emit("error", "Cannot load script: Device not connected"); return { success: false }; } try { let scriptContent; let scriptUrl; if (scriptData.url) { if (scriptData.url.toLowerCase().endsWith(".funscript") || scriptData.type === "funscript") { try { const response = await fetch(scriptData.url); if (!response.ok) { throw new Error(`Failed to fetch funscript: ${response.status}`); } scriptContent = await response.json(); if (options.invertScript && scriptContent.actions) { scriptContent.actions = scriptContent.actions.map((action) => ({ ...action, pos: 100 - action.pos, })); } const blob = new Blob([JSON.stringify(scriptContent)], { type: "application/json", }); const uploadedUrl = await this._api.uploadScript(blob); if (!uploadedUrl) { throw new Error("Failed to upload funscript"); } scriptUrl = uploadedUrl; } catch (error) { console.error("Error processing funscript URL:", error); scriptUrl = scriptData.url; } } else { try { const response = await fetch(scriptData.url); if (!response.ok) { throw new Error(`Failed to fetch script: ${response.status}`); } const fileExtension = scriptData.url.toLowerCase().split(".").pop(); if (fileExtension === "csv") { let csvText = await response.text(); // Apply inversion directly to CSV data if (options.invertScript) { const lines = csvText.split("\n"); const hasHeader = isNaN(parseFloat(lines[0].split(",")[0])); const startIndex = hasHeader ? 1 : 0; for (let i = startIndex; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; const columns = line.split(","); if (columns.length >= 2) { const pos = parseFloat(columns[1].trim()); if (!isNaN(pos)) { columns[1] = (100 - pos).toString(); lines[i] = columns.join(","); } } } csvText = lines.join("\n"); } const blob = new Blob([csvText], { type: "text/csv", }); const uploadedUrl = await this._api.uploadScript(blob); if (!uploadedUrl) { throw new Error("Failed to upload CSV script"); } scriptUrl = uploadedUrl; } else { // For other file types, use URL directly scriptUrl = scriptData.url; } } catch (error) { console.error("Error processing script URL:", error); scriptUrl = scriptData.url; } } } else if (scriptData.content) { scriptContent = { ...scriptData.content }; if (options.invertScript && scriptContent.actions) { scriptContent.actions = scriptContent.actions.map((action) => ({ ...action, pos: 100 - action.pos, })); } const blob = new Blob([JSON.stringify(scriptContent)], { type: "application/json", }); const uploadedUrl = await this._api.uploadScript(blob); if (!uploadedUrl) { this.emit("error", "Failed to upload script"); return { success: false }; } scriptUrl = uploadedUrl; } else { this.emit("error", "Invalid script data: Either URL or content must be provided"); return { success: false }; } const success = await this._api.setupScript(scriptUrl); if (success) { this.emit("scriptLoaded", { url: scriptUrl, options, }); return { success: true }; } else { this.emit("error", "Failed to set up script with device"); return { success: false }; } } catch (error) { console.error("Handy: Error loading script:", error); this.emit("error", `Script loading error: ${error instanceof Error ? error.message : String(error)}`); return { success: false }; } } /** * Play the loaded script at the specified time * @param timeMs Current time in milliseconds * @param playbackRate Playback rate (1.0 = normal speed) * @param loop Whether to loop the script */ async play(timeMs, playbackRate = 1.0, loop = false) { if (!this.isConnected) { this.emit("error", "Cannot play: Device not connected"); return false; } try { const hspState = await this._api.play(timeMs, playbackRate, loop); if (hspState) { this._isPlaying = hspState.play_state === 1 || hspState.play_state === "1"; this.emit("playbackStateChanged", { isPlaying: this._isPlaying, timeMs, playbackRate, loop, }); return true; } else { this.emit("error", "Failed to start playback"); return false; } } catch (error) { console.error("Handy: Error playing script:", error); this.emit("error", `Playback error: ${error instanceof Error ? error.message : String(error)}`); return false; } } /** * Stop playback */ async stop() { if (!this.isConnected) { this.emit("error", "Cannot stop: Device not connected"); return false; } try { const hspState = await this._api.stop(); if (hspState) { this._isPlaying = hspState.play_state === 1 || hspState.play_state === "1"; this.emit("playbackStateChanged", { isPlaying: this._isPlaying, }); return true; } else { this.emit("error", "Failed to stop playback"); return false; } } catch (error) { console.error("Handy: Error stopping script:", error); this.emit("error", `Stop playback error: ${error instanceof Error ? error.message : String(error)}`); return false; } } /** * Synchronize device time with provided time * @param timeMs Current time in milliseconds */ async syncTime(timeMs, filter = 0.5) { if (!this.isConnected || !this._isPlaying) { return false; } try { return await this._api.syncVideoTime(timeMs, filter); } catch (error) { console.error("Handy: Error syncing time:", error); return false; } } /** * Get device-specific information */ getDeviceInfo() { if (!this._deviceInfo) return null; return { id: this.id, name: this.name, type: this.type, firmware: this._deviceInfo.fw_version, hardware: this._deviceInfo.hw_model_name, sessionId: this._deviceInfo.session_id, ...this._deviceInfo, }; } /** * Set up event handlers for the device */ _setupEventHandlers() { if (!this._eventSource) return; this._eventSource.onerror = (error) => { console.error("EventSource error:", error); this.emit("error", "Connection to device lost"); }; this._eventSource.addEventListener("device_status", (event) => { const data = JSON.parse(event.data); this._deviceInfo = data.data.info; const connected = data.data.connected; if (connected && this._connectionState !== device_interface_1.ConnectionState.CONNECTED) { this._connectionState = device_interface_1.ConnectionState.CONNECTED; this.emit("connectionStateChanged", this._connectionState); this.emit("connected", this._deviceInfo); } else if (!connected && this._connectionState === device_interface_1.ConnectionState.CONNECTED) { this._connectionState = device_interface_1.ConnectionState.DISCONNECTED; this.emit("connectionStateChanged", this._connectionState); this.emit("disconnected"); } }); this._eventSource.addEventListener("device_connected", (event) => { const data = JSON.parse(event.data); this._deviceInfo = data.data.info; this._connectionState = device_interface_1.ConnectionState.CONNECTED; this.emit("connectionStateChanged", this._connectionState); this.emit("connected", this._deviceInfo); }); this._eventSource.addEventListener("device_disconnected", (event) => { this._connectionState = device_interface_1.ConnectionState.DISCONNECTED; this.emit("connectionStateChanged", this._connectionState); this.emit("disconnected"); }); this._eventSource.addEventListener("mode_changed", (event) => { this._isPlaying = false; this.emit("playbackStateChanged", { isPlaying: false }); }); this._eventSource.addEventListener("hsp_state_changed", (event) => { var _a, _b; const data = JSON.parse(event.data); // Set isPlaying based on play_state this._isPlaying = ((_a = data.data.data) === null || _a === void 0 ? void 0 : _a.play_state) === 1 || ((_b = data.data.data) === null || _b === void 0 ? void 0 : _b.play_state) === "1"; this.emit("playbackStateChanged", { isPlaying: this._isPlaying }); }); } /** * Load device settings after connection */ async _loadDeviceSettings() { if (!this.isConnected) return; try { // Get device offset const offset = await this._api.getOffset(); if (offset !== undefined) { this._config.offset = offset; } // Get stroke settings const strokeSettings = await this._api.getStrokeSettings(); if (strokeSettings) { this._config.stroke = { min: strokeSettings.min, max: strokeSettings.max, }; } // Emit config updated event this.emit("configChanged", this._config); } catch (error) { console.error("Handy: Error loading device settings:", error); } } } exports.HandyDevice = HandyDevice;