UNPKG

ive-connect

Version:

A universal haptic device control library for interactive experiences

563 lines (562 loc) 22.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ButtplugDevice = void 0; /** * Buttplug Device Implementation * * Implements the HapticDevice interface for Buttplug devices */ const device_interface_1 = require("../../core/device-interface"); const events_1 = require("../../core/events"); const buttplug_api_1 = require("./buttplug-api"); const types_1 = require("./types"); const buttplug_server_1 = require("./buttplug-server"); const command_helpers_1 = require("./command-helpers"); const parseCSVToFunscript_1 = require("../../utils/parseCSVToFunscript"); /** * Default Buttplug configuration */ const DEFAULT_CONFIG = { id: "buttplug", name: "Buttplug Devices", enabled: true, connectionType: types_1.ButtplugConnectionType.LOCAL, clientName: (0, buttplug_server_1.generateClientName)(), strokeRange: { min: 0, max: 1 }, allowedFeatures: { vibrate: true, rotate: true, linear: true, }, devicePreferences: {}, }; /** * Buttplug device implementation */ class ButtplugDevice extends events_1.EventEmitter { constructor(config) { super(); this._connectionState = device_interface_1.ConnectionState.DISCONNECTED; this._isPlaying = false; this._loadedScript = null; this._currentScriptActions = []; this._lastActionIndex = -1; this._playbackInterval = null; this._playbackStartTime = 0; this._playbackRate = 1.0; this._loopPlayback = false; this.id = "buttplug"; this.name = "Buttplug Devices"; this.type = "buttplug"; this.capabilities = [ device_interface_1.DeviceCapability.VIBRATE, device_interface_1.DeviceCapability.ROTATE, device_interface_1.DeviceCapability.LINEAR, ]; this._config = { ...DEFAULT_CONFIG }; if (config) { Object.assign(this._config, config); } this._api = new buttplug_api_1.ButtplugApi(this._config.clientName); this._setupApiEventHandlers(); } /** * Get connected state */ get isConnected() { return this._connectionState === device_interface_1.ConnectionState.CONNECTED; } /** * Get playing state */ get isPlaying() { return this._isPlaying; } /** * Connect to Buttplug server */ async connect(config) { try { // Update config if provided if (config) { await this.updateConfig(config); } // Check if WebBluetooth is supported for local connections if (this._config.connectionType === types_1.ButtplugConnectionType.LOCAL && !(0, buttplug_server_1.isWebBluetoothSupported)()) { this.emit("error", "WebBluetooth is not supported in this browser or device"); return false; } // Update connection state this._connectionState = device_interface_1.ConnectionState.CONNECTING; this.emit("connectionStateChanged", this._connectionState); // Connect to the server const success = await this._api.connect(this._config.connectionType, this._config.serverUrl); if (success) { this._connectionState = device_interface_1.ConnectionState.CONNECTED; this.emit("connectionStateChanged", this._connectionState); this.emit("connected", this.getDeviceInfo()); // Start scanning for devices automatically this._api.startScanning().catch((error) => { console.error("Error starting device scan:", error); }); return true; } else { this._connectionState = device_interface_1.ConnectionState.DISCONNECTED; this.emit("connectionStateChanged", this._connectionState); this.emit("error", "Failed to connect to Buttplug server"); return false; } } catch (error) { console.error("Buttplug: Error connecting to server:", 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 server */ async disconnect() { try { // Stop playback if active if (this._isPlaying) { await this.stop(); } // Disconnect from server await this._api.disconnect(); // Update state this._connectionState = device_interface_1.ConnectionState.DISCONNECTED; this.emit("connectionStateChanged", this._connectionState); this.emit("disconnected"); return true; } catch (error) { console.error("Buttplug: Error disconnecting:", error); this._connectionState = device_interface_1.ConnectionState.DISCONNECTED; this.emit("connectionStateChanged", this._connectionState); this.emit("disconnected"); return true; // Return true anyway for better UX } } /** * Get current configuration */ getConfig() { return { ...this._config }; } /** * Update configuration */ async updateConfig(config) { // Update local config if (config.connectionType !== undefined) { this._config.connectionType = config.connectionType; } if (config.serverUrl !== undefined) { this._config.serverUrl = config.serverUrl; } if (config.clientName !== undefined) { this._config.clientName = config.clientName; } if (config.strokeRange !== undefined) { this._config.strokeRange = { min: config.strokeRange.min, max: config.strokeRange.max, }; } if (config.allowedFeatures !== undefined) { this._config.allowedFeatures = { ...this._config.allowedFeatures, ...config.allowedFeatures, }; } if (config.devicePreferences !== undefined) { // Update device preferences in the API for (const [index, prefs] of Object.entries(config.devicePreferences)) { const deviceIndex = Number(index); this._api.setDevicePreference(deviceIndex, prefs); } // Update local config this._config.devicePreferences = { ...this._config.devicePreferences, ...config.devicePreferences, }; } // 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 */ async loadScript(scriptData, options = { invertScript: false }) { var _a, _b, _c; // Parse script data let scriptContent; try { if (scriptData.content) { // If content is directly provided scriptContent = scriptData.content; } else if (scriptData.url) { // If URL is provided, fetch the script try { console.log(`[BUTTPLUG-SCRIPT] Fetching script from URL: ${scriptData.url}`); const response = await fetch(scriptData.url); if (!response.ok) { throw new Error(`Failed to fetch script: ${response.status} ${response.statusText}`); } // Determine if it's a CSV or JSON (funscript) based on file extension const fileExtension = scriptData.url.toLowerCase().split(".").pop(); if (fileExtension === "csv") { // Handle CSV file const csvText = await response.text(); scriptContent = (0, parseCSVToFunscript_1.parseCSVToFunscript)(csvText); console.log(`[BUTTPLUG-SCRIPT] CSV loaded and converted to funscript format, actions:`, (_a = scriptContent.actions) === null || _a === void 0 ? void 0 : _a.length); } else { // Handle JSON file (funscript) try { scriptContent = await response.json(); console.log(`[BUTTPLUG-SCRIPT] Script loaded successfully, actions:`, (_b = scriptContent.actions) === null || _b === void 0 ? void 0 : _b.length); } catch (parseError) { // If JSON parsing fails, try as CSV const text = await response.text(); try { // First try to parse as JSON again with some cleanup scriptContent = JSON.parse(text.trim()); } catch (_d) { // If that fails, try CSV parsing scriptContent = (0, parseCSVToFunscript_1.parseCSVToFunscript)(text); } console.log(`[BUTTPLUG-SCRIPT] File loaded and parsed as CSV, actions:`, (_c = scriptContent.actions) === null || _c === void 0 ? void 0 : _c.length); } } } catch (error) { this.emit("error", `Failed to fetch script: ${error instanceof Error ? error.message : String(error)}`); return { success: false }; } } else { this.emit("error", "Invalid script data: Either URL or content must be provided"); return { success: false }; } if (!this.isConnected) { this.emit("error", "Cannot load script: Not connected to a server"); return { success: false, scriptContent }; } // Validate script format (basic checks for funscript) if (!scriptContent || !scriptContent.actions || !Array.isArray(scriptContent.actions)) { this.emit("error", "Invalid script format: Missing actions array"); console.error("[BUTTPLUG-SCRIPT] Invalid script format:", scriptContent); return { success: false, scriptContent }; } // Apply inversion to script actions if needed let actions = [...scriptContent.actions]; if (options.invertScript) { console.log("[BUTTPLUG-SCRIPT] Applying inversion to script"); actions = actions.map((action) => ({ ...action, pos: 100 - action.pos, })); } // Sort actions by timestamp actions.sort((a, b) => a.at - b.at); // Store the script and actions this._loadedScript = scriptContent; this._currentScriptActions = actions; this._lastActionIndex = -1; this.emit("scriptLoaded", { type: scriptData.type || "funscript", name: scriptContent.name || "Unnamed Script", actions: this._currentScriptActions.length, }); return { success: true, scriptContent }; } catch (error) { console.error("Buttplug: Error loading script:", error); this.emit("error", `Script loading error: ${error instanceof Error ? error.message : String(error)}`); return { success: false }; } } /** * Play the loaded script */ async play(timeMs, playbackRate = 1.0, loop = false) { if (!this.isConnected) { this.emit("error", "Cannot play: Not connected to a server"); return false; } if (!this._loadedScript || !this._currentScriptActions.length) { this.emit("error", "Cannot play: No script loaded"); return false; } try { // Stop any existing playback if (this._isPlaying) { await this.stop(); } // Set playback parameters this._playbackStartTime = Date.now() - timeMs; this._playbackRate = playbackRate; this._loopPlayback = loop; this._lastActionIndex = -1; // Create command executor for all devices (no stroke range here) const devices = this._api.getDevices(); const preferences = this._api.getDevicePreferences(); const executor = (0, command_helpers_1.createMultiDeviceCommandExecutor)(this._api, devices, preferences, false); // Start playback this._isPlaying = true; // Create an interval to check for actions this._playbackInterval = setInterval(() => { this._processActions(executor); }, 20); this.emit("playbackStateChanged", { isPlaying: this._isPlaying, timeMs, playbackRate, loop, }); return true; } catch (error) { console.error("Buttplug: Error starting playback:", error); this._isPlaying = false; this.emit("error", `Playback error: ${error instanceof Error ? error.message : String(error)}`); this.emit("playbackStateChanged", { isPlaying: false }); return false; } } /** * Stop playback */ async stop() { if (!this.isConnected) { this.emit("error", "Cannot stop: Not connected to a server"); return false; } try { // Clear playback interval if (this._playbackInterval !== null) { clearInterval(this._playbackInterval); this._playbackInterval = null; } // Stop all devices await this._api.stopAllDevices(); // Update playback state this._isPlaying = false; this._lastActionIndex = -1; this.emit("playbackStateChanged", { isPlaying: false }); return true; } catch (error) { console.error("Buttplug: Error stopping playback:", error); this._isPlaying = false; this.emit("error", `Stopping playback error: ${error instanceof Error ? error.message : String(error)}`); this.emit("playbackStateChanged", { isPlaying: false }); return false; } } /** * Sync playback time */ async syncTime(timeMs) { if (!this.isConnected || !this._isPlaying) { return false; } try { // Update the playback start time based on the current time this._playbackStartTime = Date.now() - timeMs; return true; } catch (error) { console.error("Buttplug: Error syncing time:", error); return false; } } /** * Get device information */ getDeviceInfo() { // Get connected devices const devices = this._api.getDevices(); if (devices.length === 0) { return { id: this.id, name: this.name, type: this.type, deviceCount: 0, devices: [], }; } return { id: this.id, name: this.name, type: this.type, deviceCount: devices.length, devices: devices.map((device) => ({ index: device.index, name: device.name, features: [ device.canVibrate ? "vibrate" : null, device.canRotate ? "rotate" : null, device.canLinear ? "linear" : null, ].filter(Boolean), })), }; } /** * Process script actions based on current time */ _processActions(executor) { var _a; if (!this._isPlaying || !this._currentScriptActions.length) { return; } // Calculate current time in the script const currentTime = Date.now(); const elapsedMs = (currentTime - this._playbackStartTime) * this._playbackRate; // Find the action for the current time const actionIndex = this._findActionIndexForTime(elapsedMs); // If we reached the end of the script if (actionIndex === this._currentScriptActions.length - 1 && elapsedMs > ((_a = this._currentScriptActions[actionIndex]) === null || _a === void 0 ? void 0 : _a.at) + 1000) { if (this._loopPlayback) { // Reset for loop playback this._playbackStartTime = Date.now(); this._lastActionIndex = -1; return; } else { // We're past the end of the script, stop playback this.stop().catch(console.error); return; } } // If we have a new action to execute if (actionIndex !== this._lastActionIndex && actionIndex >= 0) { const action = this._currentScriptActions[actionIndex]; const prevAction = actionIndex > 0 ? this._currentScriptActions[actionIndex - 1] : { pos: 0 }; // Calculate duration for linear movement based on time with previous action let durationMs = 500; // Default duration if we can't determine if (actionIndex < this._currentScriptActions.length - 1) { const prevAction = this._currentScriptActions[actionIndex - 1]; durationMs = (action === null || action === void 0 ? void 0 : action.at) - (prevAction === null || prevAction === void 0 ? void 0 : prevAction.at); // Enforce a minimum duration to prevent erratic movement durationMs = Math.max(100, durationMs); } // Get current stroke range from config (live updates) const strokeRange = this._config.strokeRange || { min: 0, max: 1 }; // Execute the action on all devices with current stroke range executor .executeAction(action.pos, prevAction.pos, durationMs, strokeRange) .catch((error) => { console.error("Error executing action:", error); }); this._lastActionIndex = actionIndex; } } /** * Find the action index for the given time */ _findActionIndexForTime(timeMs) { if (!this._currentScriptActions.length) { return -1; } // If we're past the end of the script if (timeMs > this._currentScriptActions[this._currentScriptActions.length - 1].at) { return this._currentScriptActions.length - 1; } // If we're before the beginning of the script if (timeMs < this._currentScriptActions[0].at) { return 0; } // Binary search for the action let low = 0; let high = this._currentScriptActions.length - 1; let bestIndex = -1; while (low <= high) { const mid = Math.floor((low + high) / 2); if (this._currentScriptActions[mid].at <= timeMs) { bestIndex = mid; low = mid + 1; } else { high = mid - 1; } } // When returning bestIndex, we're always getting // the last action that has a timestamp <= timeMs // Let's return the next action instead return bestIndex < this._currentScriptActions.length - 1 ? bestIndex + 1 : bestIndex; } /** * Set up event handlers for the Buttplug API */ _setupApiEventHandlers() { // Forward connection state changes this._api.on("connectionStateChanged", (state) => { let connectionState; switch (state) { case types_1.ButtplugConnectionState.CONNECTED: connectionState = device_interface_1.ConnectionState.CONNECTED; break; case types_1.ButtplugConnectionState.CONNECTING: connectionState = device_interface_1.ConnectionState.CONNECTING; break; default: connectionState = device_interface_1.ConnectionState.DISCONNECTED; break; } this._connectionState = connectionState; this.emit("connectionStateChanged", connectionState); }); // Forward device events this._api.on("deviceAdded", (deviceInfo) => { this.emit("deviceAdded", deviceInfo); }); this._api.on("deviceRemoved", (deviceInfo) => { this.emit("deviceRemoved", deviceInfo); }); // Forward errors this._api.on("error", (error) => { this.emit("error", error); }); // Forward scanning state changes this._api.on("scanningChanged", (scanning) => { this.emit("scanningChanged", scanning); }); // Forward device preference changes this._api.on("devicePreferenceChanged", (data) => { this.emit("devicePreferenceChanged", data); // Update local config if (!this._config.devicePreferences) { this._config.devicePreferences = {}; } this._config.devicePreferences[data.deviceIndex] = data.preference; this.emit("configChanged", this._config); }); } } exports.ButtplugDevice = ButtplugDevice;