const MenuButton = videojs.getComponent('MenuButton'); const VideoJsMenuItem = videojs.getComponent('MenuItem'); /* ------------------------- Menu Components ------------------------- */ class ActuatorMenuButton extends MenuButton { constructor(player, { actuator, funscripts = [], onAssign } = {}) { super(player, { title: actuator?.name ?? 'Actuator', name: 'ActuatorMenuButton', actuator, funscripts, onAssign, }); } createItems() { const { actuator, funscripts, onAssign } = this.options_ || {}; if (!actuator || !funscripts?.length) return []; return funscripts.map(funscript => new FunscriptMenuItem(this.player_, { actuator, funscript, onAssign, }) ); } } class FunscriptMenuItem extends VideoJsMenuItem { constructor(player, { actuator, funscript, onAssign } = {}) { super(player, { label: funscript.name, selectable: true }); this.actuator = actuator; this.funscript = funscript; this.onAssign = onAssign; // Pre-select if this actuator already has this funscript assigned this.selected(this.actuator.assignedFunscript?.url === this.funscript.url); this.on('click', () => { if (!this.actuator || !this.onAssign) return; // Use centralized assignment method this.onAssign?.(this, this.actuator, this.funscript); }); } } /* ------------------------- Helper ------------------------- */ function pickInitialFunscript(actuatorType, availableFunscripts) { actuatorType = actuatorType.toLowerCase(); if (actuatorType.includes('vibrator')) { return availableFunscripts.find(f => f.name.includes('vibrate')) ?? availableFunscripts[0]; } if (actuatorType.includes('thrust')) { return availableFunscripts.find(f => f.name.includes('thrust')) ?? availableFunscripts[0]; } return availableFunscripts[0]; } /* ------------------------- Main Plugin ------------------------- */ class FunscriptPlayer { constructor(player, { debug = false, funscripts, ...options } = {}) { this.player = player; this.debug = debug; this.funscriptCache = new Map(); // key: url, value: parsed funscript this.funscripts = (funscripts || []).map(f => { if (typeof f === 'string') { const url = f; const name = url.split('/').pop().split('?')[0]; return { name, url }; } return f; }); this.options = options; this.menuButton = null; this.devices = []; player.on('toyConnected', () => this.refreshMenu()); player.on('toyDisconnected', () => this.refreshMenu()); player.on('pause', () => this.stopAllDevices()); } async init() { const connector = new window.buttplug.ButtplugBrowserWebsocketClientConnector("ws://localhost:12345"); this.client = new window.buttplug.ButtplugClient(this.options?.buttplugClientName || "Funscripts VideoJS Client"); this.client.addListener("deviceadded", device => { this.addDevice(device); this.debug && console.log("Device added:", device.name, device); this.player.trigger('toyConnected'); }); this.client.addListener("deviceremoved", device => { this.removeDevice(device); this.debug && console.log("Device removed:", device.name); this.player.trigger('toyDisconnected'); }); try { await this.client.connect(connector); await this.client.startScanning(); this.initVideoListeners(); this.debug && console.log("[buttplug] Connected and scanning"); } catch (err) { console.error("[buttplug] Connection error:", err); } } async loadFunscript(funscript) { try { const response = await fetch(funscript.url); if (!response.ok) throw new Error(`Failed to load ${funscript.name}`); return await response.json(); } catch (err) { console.error("[funscripts] Error loading funscript:", funscript.name, err); return null; } } getFunscriptValueAtTime(funscriptData, timeMs) { if (!funscriptData?.actions?.length) return null; let lastAction = null; for (const action of funscriptData.actions) { if (action.at <= timeMs) { lastAction = action; } else { break; } } return lastAction ? lastAction.pos / 100 : null; // normalize 0–1 } initVideoListeners() { this.player.on('pause', () => { this.debug && console.log('Video paused. Stopping devices...'); this.stopAllDevices(); }); this.player.on('timeupdate', () => { if (this.player.paused() || this.player.ended()) return; const currentTime = this.player.currentTime() * 1000; this.devices.forEach(device => { device.actuators.forEach(actuator => { const fun = actuator.assignedFunscript; if (!fun) return; const funscriptData = this.funscriptCache.get(fun.url); if (!funscriptData) return; const position = this.getFunscriptValueAtTime(funscriptData, currentTime); this.debug && console.log(`${fun.name} position=${position}`); if (position !== null) { this.sendToDevice(device, actuator, position); } }); }); }); } async stopAllDevices() { if (!this.client || !this.client.devices) return; for (const [deviceIndex, device] of this.client.devices.entries()) { const scalarCmds = device.messageAttributes?.ScalarCmd || []; for (const cmd of scalarCmds) { try { await this.sendToDevice(device, { index: cmd.Index, name: `${device.name} - ${cmd.ActuatorType} #${cmd.Index + 1}` }, 0); } catch (err) { console.warn(`[buttplug] Failed to stop ${device.name}`, err); } } } } async sendToDevice(device, actuator, position) { try { this.client.devices[device.index].vibrate(position); if (this.debug) { console.log(`[buttplug] Sent to ${actuator.name}:`, position); } } catch (err) { console.error("[buttplug] Error sending command:", err); } } /* ------------------------- Device Tracking ------------------------- */ addDevice(device) { const scalarCmds = device._deviceInfo?.DeviceMessages?.ScalarCmd || []; const actuators = scalarCmds.map((cmd, i) => { const assignedFunscript = pickInitialFunscript(cmd.ActuatorType, this.funscripts); if (assignedFunscript && !this.funscriptCache.has(assignedFunscript.url)) { this.loadFunscript(assignedFunscript).then(data => { if (data) this.funscriptCache.set(assignedFunscript.url, data); }); } return { name: `${device.name} - ${cmd.ActuatorType} #${cmd.Index + 1}`, type: cmd.ActuatorType, index: cmd.Index, assignedFunscript, deviceIndex: this.devices.length, // track device index }; }); this.devices.push({ name: device._deviceInfo.DeviceName, index: this.devices.length, actuators, }); } removeDevice(device) { this.devices = this.devices.filter(d => d.name !== device.name); } /* ------------------------- Central Assignment ------------------------- */ async assignFunscript(deviceIndex, actuatorIndex, funscript) { const actuator = this.devices[deviceIndex]?.actuators[actuatorIndex]; if (!actuator) return; actuator.assignedFunscript = funscript; if (!this.funscriptCache.has(funscript.url)) { const data = await this.loadFunscript(funscript); if (data) this.funscriptCache.set(funscript.url, data); } this.debug && console.log(`Assigned ${funscript.name} to ${actuator.name}`); } /* ------------------------- Menu Management ------------------------- */ createMenuButtons() { if (this.menuButtons?.length) { this.menuButtons.forEach(btn => this.player.controlBar.removeChild(btn)); } this.menuButtons = []; this.devices.forEach(device => { device.actuators.forEach(actuator => { const button = new ActuatorMenuButton(this.player, { actuator, funscripts: this.funscripts, onAssign: async (item, act, funscript) => { await this.assignFunscript(device.index, act.index, funscript); const menuItems = item.parentComponent_.children() .filter(c => c instanceof FunscriptMenuItem); menuItems.forEach(i => { i.selected(i.funscript.url === funscript.url); }); } }); const placementIndex = Math.max( 0, this.options?.placementIndex ?? (this.player.controlBar.children().length - 2) ); this.player.controlBar.addChild(button, { componentClass: 'funscriptSelector' }, placementIndex); setTimeout(() => { button.addClass('vjs-funscript-selector'); button.removeClass('vjs-hidden'); }, 0); this.menuButtons.push(button); }); }); } refreshMenu() { this.createMenuButtons(); } } /* ------------------------- Register Plugin ------------------------- */ videojs.registerPlugin("funscriptPlayer", async function (options) { try { const plugin = new FunscriptPlayer(this, options); await plugin.init(); return plugin; } catch (err) { console.error("Funscripts plugin failed to initialize:", err); } });