306 lines
9.2 KiB
JavaScript
306 lines
9.2 KiB
JavaScript
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);
|
||
}
|
||
});
|