funscript playback ui basic actuator menu list
Some checks are pending
ci / build (push) Waiting to run
ci / test (push) Waiting to run

This commit is contained in:
CJ_Clippy 2025-09-01 17:37:11 -08:00
parent e0e10fd926
commit 28f8b7e94e
20 changed files with 591 additions and 578 deletions

View File

@ -1 +1 @@
{"last_found_secrets": [{"match": "a5cd2a1a54ccc453d07fae38adafb7b0791b4824afad85d962266e48a54be75a", "name": "Base64 Basic Authentication - commit://staged/services/tracker-helper/test/app.test.ts"}]}
{"last_found_secrets": [{"name": "Generic High Entropy Secret - commit://staged/services/our/src/client/vod.js", "match": "3baceea0f1ce69c8007c0619c3e518cfd24135212b012f3b4d99e82fb1cced6f"}, {"name": "Generic High Entropy Secret - commit://staged/services/our/src/client/vod.js", "match": "4d24f894db33032a2666656e35e34fe3281490468e350e0be5aa8b0faabef682"}]}

View File

@ -20,8 +20,7 @@ echo(" * Remote pin the IPFS CID")
echo(" [Press Enter When Complete...]")
_ = stdin()
echo(" * Create magnet link. ex: `mktorrent --web-seed https://futureporn-b2.b-cdn.net/projektmelody-fansly-2025-08-27.mp4 ~/Documents/voddo/projektmelody-fansly-2025-08-27.mp4`")
echo(" * Create magnet link. ex: `torf ~/Downloads/projektmelody-chaturbate-2025-08-27.mp4 --notorrent --notracker`")
echo(" * Create magnet link. ex: `imdl torrent create --link ~/Documents/voddo/projektmelody-fansly-2025-08-27.mp4`")
echo(" [Press Enter When Complete...]")
_ = stdin()

View File

@ -39,6 +39,9 @@ ENV LD_LIBRARY_PATH="/app/whisper.cpp/build/src:/app/whisper.cpp/build/ggml/src:
# Install b2-cli
RUN wget https://github.com/Backblaze/B2_Command_Line_Tool/releases/download/v4.4.1/b2-linux -O /usr/local/bin/b2 && chmod +x /usr/local/bin/b2
# Install imdl
RUN wget https://github.com/casey/intermodal/releases/download/v0.1.14/imdl-v0.1.14-x86_64-unknown-linux-musl.tar.gz -O /tmp/imdl.tar.gz && tar xvzf /tmp/imdl.tar.gz && cp /tmp/imdl /usr/local/bin/imdl
# Copy and install dependencies
COPY package.json package-lock.json ./
RUN npm install --ignore-scripts=false --foreground-scripts --verbose

View File

@ -70,6 +70,7 @@
"devDependencies": {
"@eslint/compat": "^1.3.1",
"@eslint/js": "^9.31.0",
"buttplug": "^3.2.2",
"chokidar": "^4.0.3",
"esbuild": "^0.25.9",
"esbuild-copy-static-files": "^0.1.0",
@ -5527,6 +5528,19 @@
"esbuild": ">=0.18"
}
},
"node_modules/buttplug": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/buttplug/-/buttplug-3.2.2.tgz",
"integrity": "sha512-TGkQzG6dxEjuFX29eRoWkh82vsQhGQ+E98tZtN8fWn1NOG7v/9H0FFkNXrpmeRt9FFS0GdHTvubfZ8dcIPGSAA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"class-transformer": "^0.5.1",
"eventemitter3": "^5.0.1",
"reflect-metadata": "^0.2.1",
"ws": "^8.16.0"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@ -5956,6 +5970,13 @@
"node": ">=18"
}
},
"node_modules/class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"dev": true,
"license": "MIT"
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
@ -7057,6 +7078,13 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"dev": true,
"license": "MIT"
},
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@ -10269,6 +10297,13 @@
"node": ">= 12.13.0"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/require-addon": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.1.0.tgz",
@ -12258,6 +12293,28 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -26,6 +26,7 @@
"devDependencies": {
"@eslint/compat": "^1.3.1",
"@eslint/js": "^9.31.0",
"buttplug": "^3.2.2",
"chokidar": "^4.0.3",
"esbuild": "^0.25.9",
"esbuild-copy-static-files": "^0.1.0",
@ -110,4 +111,4 @@
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
}

View File

@ -1,269 +0,0 @@
// {{!--
// Script 1: Load Buttplug.js from Skypack CDN and expose it to window.buttplug
// --}}
// <script type="module">
// import {
// ButtplugClient,
// ButtplugBrowserWebsocketClientConnector
// } from 'https://cdn.skypack.dev/buttplug';
// window.buttplug = { ButtplugClient, ButtplugBrowserWebsocketClientConnector };
// </script>
{{!--
{{!--
Script 3: Main ButtplugPlugin class handles connection, syncing, and device control
--}}
<script type="module">
class ButtplugPlugin extends videojs.getPlugin('plugin') {
constructor(player, options) {
super(player, options);
this.funscripts = {}; // { vibrate: [...], thrust: [...] }
this.activeFunscript = null;
this.currentActionIndex = 0;
this.lastPositionSent = null;
this.devicePaused = false;
this.client = new window.buttplug.ButtplugClient("future.porn");
this.statusIndicator = null;
// Preload funscripts if provided as { vibrate: url, thrust: url }
if (options?.funscripts) {
for (const [name, url] of Object.entries(options.funscripts)) {
this.loadFunscript(name, url);
}
}
this.connectToIntiface();
this.showStatusIndicator();
// Player event hooks
this.on(player, 'timeupdate', this.handleTimeUpdate);
this.on(player, 'pause', this.handlePause);
this.on(player, 'playing', this.handlePlay);
this.on(player, 'seeking', this.handleSeek);
this.on(player, 'ended', this.handleEnded);
}
// Inside ButtplugPlugin class
// Helper to get an SVG string from your icons object
getIcon(name, size = 20, color = 'currentColor') {
const svg = window.icons?.[name]; // make sure icons are exposed globally
if (!svg) return `<!-- icon "${name}" not found -->`;
// Remove any existing width/height/fill
let sizedSvg = svg
.replace(/\s(width|height|fill)="[^"]*"/g, '')
.replace(/<svg([^>]*)>/, `<svg$1 width="${size}" height="${size}" fill="${color}">`);
return sizedSvg;
}
// Rebuild the status indicator UI
updateStatusIndicator() {
if (!this.statusIndicator) return;
const connected = !!this.client?.connected;
const funscriptLoaded = !!this.activeFunscript;
const connectionIconHtml = this.getIcon('buttplug3', 20, connected ? 'green' : 'red');
const funscriptIconHtml = funscriptLoaded ? this.getIcon('buttplug3', 20, 'blue') : '';
const menuItemsHtml = Object.keys(this.funscripts).map(name => {
const checked = this.activeFunscript === this.funscripts[name] ? 'true' : 'false';
const selectedClass = checked === 'true' ? 'vjs-selected' : '';
return `
<li class="vjs-menu-item ${selectedClass}" role="menuitemradio" tabindex="-1" aria-disabled="false" aria-checked="${checked}" data-funscript-name="${name}">
<span class="vjs-menu-item-text">${name}</span>
<span class="vjs-control-text" aria-live="polite">${checked === 'true' ? ', selected' : ''}</span>
</li>
`;
}).join('');
const noScriptChecked = !this.activeFunscript ? 'true' : 'false';
const noScriptHtml = `
<li class="vjs-menu-item ${noScriptChecked === 'true' ? 'vjs-selected' : ''}" role="menuitemradio" tabindex="-1" aria-disabled="false" aria-checked="${noScriptChecked}" data-funscript-name="">
<span class="vjs-menu-item-text">No Script</span>
<span class="vjs-control-text" aria-live="polite">${noScriptChecked === 'true' ? ', selected' : ''}</span>
</li>
`;
this.statusIndicator.el().innerHTML = `
<div class="buttplug-status" style="display:flex; align-items:center; gap:4px;">
${connectionIconHtml}
${funscriptIconHtml}
<div class="vjs-menu-button vjs-menu-button-popup vjs-control vjs-button" tabindex="0" aria-disabled="false" title="Funscript" aria-haspopup="true" aria-expanded="false">
<button type="button" class="vjs-button" aria-live="polite" title="Funscript">
${this.getIcon('buttplug3', 20)} <!-- SVG icon here -->
<span class="vjs-control-text">Funscript</span>
</button>
<div class="vjs-menu">
<ul class="vjs-menu-content" role="menu">
${noScriptHtml}
${menuItemsHtml}
</ul>
</div>
</div>
</div>
`;
// click handlers
const items = this.statusIndicator.el().querySelectorAll('.vjs-menu-item');
items.forEach(item => {
item.addEventListener('click', (e) => {
const name = item.dataset.funscriptName;
this.activeFunscript = this.funscripts[name] || null;
this.currentActionIndex = 0;
this.lastPositionSent = null;
this.updateStatusIndicator();
});
});
}
async loadFunscript(name, url) {
try {
const res = await fetch(url);
const json = await res.json();
this.funscripts[name] = json.actions || [];
console.log(`[buttplug] Loaded funscript "${name}" with ${this.funscripts[name].length} actions`);
if (!this.activeFunscript) {
this.activeFunscript = this.funscripts[name];
}
this.updateStatusIndicator();
} catch (e) {
console.error(`[buttplug] Failed to load funscript "${name}":`, e);
}
}
async connectToIntiface() {
console.log(`[buttplug] connecting to intiface`);
try {
if (this.client.connected) {
console.log("[buttplug] Already connected");
return;
}
const connector = new window.buttplug.ButtplugBrowserWebsocketClientConnector("ws://localhost:12345");
await this.client.connect(connector);
console.log("[buttplug] Connected to Intiface");
this.updateStatusIndicator();
this.client.addListener('disconnect', this.handleDisconnect);
this.client.addListener("deviceadded", (device) => {
console.log(`[buttplug] Device connected: ${device.name}`);
this.updateStatusIndicator();
});
this.client.addListener("deviceremoved", (device) => {
console.log(`[buttplug] Device removed: ${device.name}`);
this.updateStatusIndicator();
});
await this.client.startScanning();
console.log("[buttplug] Scanning for devices...");
} catch (e) {
console.error("[buttplug] Failed to connect to Intiface:", e);
}
}
handleDisconnect = () => {
console.log('[Buttplug] Disconnected from Intiface');
this.updateStatusIndicator();
};
showStatusIndicator() {
const controlBar = this.player.getChild('controlBar');
const insertBeforeIndex = controlBar.children().length - 3;
this.statusIndicator = controlBar.addChild('Component', {
el: videojs.dom.createEl('div', { className: 'vjs-buttplug-indicator' })
}, insertBeforeIndex);
this.updateStatusIndicator();
}
handleTimeUpdate = () => {
if (!this.activeFunscript?.length || this.devicePaused) return;
const time = this.player.currentTime() * 1000;
while (
this.currentActionIndex < this.activeFunscript.length - 1 &&
this.activeFunscript[this.currentActionIndex + 1].at <= time
) {
this.currentActionIndex++;
}
const action = this.activeFunscript[this.currentActionIndex];
if (!action || action.pos === this.lastPositionSent) return;
this.lastPositionSent = action.pos;
this.sendToDevice(action.pos);
};
async sendToDevice(position) {
if (!this.client?.connected || this.client.devices.size === 0) {
console.warn('[buttplug] No connected devices');
return;
}
try {
const devices = [...this.client.devices.values()];
if (position === null) {
await Promise.all(devices.map((d) => d.stop()));
} else {
const speed = position / 100;
await Promise.all(devices.map((d) => d.vibrate?.(speed)));
}
} catch (e) {
console.error("[buttplug] Failed to send to device:", e);
}
}
handlePause = () => {
this.devicePaused = true;
this.sendToDevice(null);
};
handlePlay = () => {
this.devicePaused = false;
};
handleEnded = () => {
this.currentActionIndex = 0;
this.lastPositionSent = null;
this.devicePaused = true;
this.sendToDevice(null);
};
handleSeek = () => {
if (!this.activeFunscript) return;
const time = this.player.currentTime() * 1000;
this.currentActionIndex = 0;
for (let i = 0; i < this.activeFunscript.length; i++) {
if (this.activeFunscript[i].at > time) break;
this.currentActionIndex = i;
}
this.lastPositionSent = null;
};
dispose() {
super.dispose();
if (this.client?.connected) {
this.client.disconnect().then(() => console.log("[buttplug] Client disconnected"));
}
}
}
videojs.registerPlugin('buttplug', ButtplugPlugin);
</script>
--}}

View File

@ -0,0 +1,31 @@
/**
* Downloads a file from a given CDN URL and triggers a browser download.
*
* @async
* @function download
* @param {string} cdnUrl - The URL of the file to download.
* @param {string} fileName - The name to give the downloaded file (including extension).
* @returns {Promise<void>} A promise that resolves once the download has been triggered.
*
* @example
* // Download an image and save as picture.jpg
* await download('https://example.com/image.jpg', 'picture.jpg');
*/
export async function download(cdnUrl, fileName) {
console.log(`downloading cdnUrl=${cdnUrl} fileName=${fileName}`)
try {
const response = await fetch(cdnUrl);
if (!response.ok) throw new Error('Network response was not ok');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('There was a problem with the fetch operation:', error);
}
}

View File

@ -1,86 +0,0 @@
import videojs from 'video.js'
const MenuButton = videojs.getComponent('MenuButton');
const Menu = videojs.getComponent('Menu');
const Component = videojs.getComponent('Component');
const Dom = videojs.dom;
/**
* Convert string to title case.
*
* @param {string} string - the string to convert
* @return {string} the returned titlecase string
*/
function toTitleCase(string) {
if (typeof string !== 'string') {
return string;
}
return string.charAt(0).toUpperCase() + string.slice(1);
}
/**
* Extend vjs button class for funscripts button.
*/
export default class ConcreteButton extends MenuButton {
/**
* Button constructor.
*
* @param {Player} player - videojs player instance
*/
constructor(player) {
super(player, {
title: player.localize('Funscript'),
name: 'ScriptButton'
});
}
/**
* Creates button items.
*
* @return {Array} - Button items
*/
createItems() {
return [];
}
/**
* Create the menu and add all items to it.
*
* @return {Menu}
* The constructed menu
*/
createMenu() {
const menu = new Menu(this.player_, { menuButton: this });
this.hideThreshold_ = 0;
// Add a title list item to the top
if (this.options_.title) {
const titleEl = Dom.createEl('li', {
className: 'vjs-menu-title',
innerHTML: toTitleCase(this.options_.title),
tabIndex: -1
});
const titleComponent = new Component(this.player_, { el: titleEl });
this.hideThreshold_ += 1;
menu.addItem(titleComponent);
}
this.items = this.createItems();
if (this.items) {
// Add menu items to the menu
for (let i = 0; i < this.items.length; i++) {
menu.addItem(this.items[i]);
}
}
return menu;
}
}

View File

@ -1,44 +0,0 @@
import videojs from 'video.js'
// Concrete classes
const VideoJsMenuItemClass = videojs.getComponent('MenuItem');
/**
* Extend vjs menu item class.
*/
export default class ConcreteMenuItem extends VideoJsMenuItemClass {
/**
* Menu item constructor.
*
* @param {Player} player - vjs player
* @param {Object} item - Item object
* @param {ConcreteButton} scriptButton - The containing button.
* @param {FunscriptSelector} plugin - This plugin instance.
*/
constructor(player, item, scriptButton, plugin) {
super(player, {
label: item.label,
selectable: true,
selected: item.selected || false
});
this.item = item;
this.scriptButton = scriptButton;
this.plugin = plugin;
}
/**
* Click event for menu item.
*/
handleClick() {
// Reset other menu items selected status.
for (let i = 0; i < this.scriptButton.items.length; ++i) {
this.scriptButton.items[i].selected(false);
}
// Set this menu item to selected, and set quality.
this.plugin.setFunscript(this.item.value);
this.selected(true);
}
}

View File

@ -1 +0,0 @@
inspired by https://github.com/chrisboustead/videojs-hls-quality-selector/tree/main

View File

@ -1,133 +0,0 @@
import videojs from 'video.js'
import ConcreteButton from './ConcreteButton.js';
import ConcreteMenuItem from './ConcreteMenuItem.js';
import './plugin.css'
const VERSION = "0.0.1"
const Plugin = videojs.getPlugin('plugin');
const defaults = {};
/**
* Funscript Selector Plugin
*/
class FunscriptSelector extends Plugin {
constructor(player, options) {
super(player);
this.options = videojs.obj.merge(defaults, options);
this.player.ready(() => {
this.player.addClass('vjs-funscript-selector');
this.createFunscriptButton();
});
// Player event hooks
// this.on(player, 'timeupdate', this.handleTimeUpdate);
this.on(player, 'pause', this.handlePause);
this.on(player, 'playing', this.handlePlay);
this.on(player, 'seeking', this.handleSeek);
this.on(player, 'ended', this.handleEnded);
}
handlePause = () => {
this.devicePaused = true;
// this.sendToDevice(null); // @todo
};
handlePlay = () => {
this.devicePaused = false;
};
handleEnded = () => {
this.currentActionIndex = 0;
this.lastPositionSent = null;
this.devicePaused = true;
// this.sendToDevice(null); // @todo
};
handleSeek = () => {
if (!this.activeFunscript) return;
const time = this.player.currentTime() * 1000;
this.currentActionIndex = 0;
for (let i = 0; i < this.activeFunscript.length; i++) {
if (this.activeFunscript[i].at > time) break;
this.currentActionIndex = i;
}
this.lastPositionSent = null;
};
/**
* Create the funscript menu button
*/
createFunscriptButton() {
const player = this.player;
this._funscriptButton = new ConcreteButton(player);
const placementIndex = player.controlBar.children().length - 2;
const buttonInstance = player.controlBar.addChild(
this._funscriptButton,
{ componentClass: 'funscriptSelector' },
this.options.placementIndex || placementIndex
);
buttonInstance.addClass('vjs-funscript-selector');
this._funscriptButton.createItems = () => this.createFunscriptItems();
this._funscriptButton.update();
}
/**
* Create funscript menu items
*/
createFunscriptItems() {
const player = this.player;
const funscripts = [
{
label: 'Vibrate',
value: player.el().querySelector('#funscript-vibrate')?.dataset.url,
},
{
label: 'Thrust',
value: player.el().querySelector('#funscript-thrust')?.dataset.url,
}
];
return funscripts.map(item => this.getFunscriptMenuItem(item));
}
/**
* Return a ConcreteMenuItem for the funscript
*/
getFunscriptMenuItem(item) {
return new ConcreteMenuItem(this.player, item, this._funscriptButton, this);
}
/**
* Called by ConcreteMenuItem when clicked
*/
setFunscript(url) {
this._currentFunscript = url;
// Trigger a custom event so your Alpine.js or other logic can pick it up
this.player.trigger('funscriptSelected', { url });
}
getCurrentFunscript() {
return this._currentFunscript;
}
}
// Include the version number.
FunscriptSelector.VERSION = VERSION;
videojs.registerPlugin('funscripts', FunscriptSelector);
export default FunscriptSelector;

View File

@ -0,0 +1,353 @@
import videojs from 'video.js';
import { ButtplugClient, ButtplugBrowserWebsocketClientConnector } from 'buttplug';
import './plugin.css';
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;
// Assign this funscript
this.actuator.assignedFunscript = this.funscript;
// Update UI: only this item is selected
this.parentComponent_.children()
.filter(c => c instanceof FunscriptMenuItem)
.forEach(c => c.selected(c === this));
// Call external callback
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 ------------------------- */
export default class FunscriptPlayer {
constructor(player, { debug = false, funscripts, ...options } = {}) {
this.player = player;
this.debug = debug;
this.funscriptCache = new Map(); // key: url, value: parsed funscript
// Normalize funscripts: ensure theyre always { name, url }
this.funscripts = (funscripts || []).map(f => {
if (typeof f === 'string') {
// Extract filename from URL
const url = f;
const name = url.split('/').pop().split('?')[0]; // remove query params
return { name, url };
}
// Already in { name, url } format
return f;
});
this.options = options;
this.menuButton = null;
this.devices = []; // track connected devices
// refresh menu when devices change
player.on('toyConnected', () => this.refreshMenu());
player.on('toyDisconnected', () => this.refreshMenu());
player.on('pause', () => this.stopAllDevices());
}
async init() {
const connector = new ButtplugBrowserWebsocketClientConnector("ws://localhost:12345");
this.client = new 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;
this.debug && console.log(`getFunscriptValueAtTime timeMs=${timeMs}`)
// Find the latest action at or before current time
let lastAction = null;
for (const action of funscriptData.actions) {
if (action.at <= timeMs) {
lastAction = action;
} else {
break;
}
}
return lastAction ? lastAction.pos / 100 : null; // normalize 01
}
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; // exit early if not playing. necessary for seeking while paused
const currentTime = this.player.currentTime() * 1000; // ms
// this.debug && console.log(`timeupdate currentTime=${currentTime}`)
this.devices.forEach(device => {
device.actuators.forEach(actuator => {
const fun = actuator.assignedFunscript;
if (!fun) return;
// this.debug && console.log(`name=${fun.name} url=${fun.url}`)
const funscriptData = this.funscriptCache.get(fun.url);
// console.log(funscriptData)
if (!funscriptData) return;
const position = this.getFunscriptValueAtTime(funscriptData, currentTime);
// this.debug && console.log(`position=${position}`)
if (position !== null) {
this.debug && console.log(`actuator.name=${actuator.name}, position=${position}`)
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 {
// console.log()
// await this.client.devices[device.index].scalar(actuator.index, position);
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);
// kick off preload asynchronously, but dont block UI
if (assignedFunscript && !this.funscriptCache.has(assignedFunscript.url)) {
this.loadFunscript(assignedFunscript).then(data => {
if (data) this.funscriptCache.set(assignedFunscript.url, data);
this.debug && console.log("Preloaded funscript:", assignedFunscript.name);
}).catch(err => {
console.error("Error preloading funscript:", assignedFunscript.name, err);
});
}
return {
name: `${device.name} - ${cmd.ActuatorType} #${cmd.Index + 1}`,
type: cmd.ActuatorType,
index: cmd.Index,
assignedFunscript,
};
});
this.devices.push({
name: device._deviceInfo.DeviceName,
index: this.devices.length,
actuators,
});
this.debug && console.log("Processed device:", device._deviceInfo.DeviceName, "actuators:", actuators);
}
removeDevice(device) {
this.devices = this.devices.filter(d => d.name !== device.name);
}
/* ------------------------- Menu Management ------------------------- */
createMenuButtons() {
// Remove existing menu buttons
if (this.menuButtons?.length) {
this.menuButtons.forEach(btn => this.player.controlBar.removeChild(btn));
}
this.menuButtons = [];
const actuators = this.devices.flatMap(device =>
device.actuators.map(actuator => ({
...actuator,
deviceName: device.name,
deviceIndex: device.index,
}))
);
actuators.forEach(actuator => {
const button = new ActuatorMenuButton(this.player, {
actuator,
funscripts: this.funscripts,
onAssign: async (item, act, funscript) => {
act.assignedFunscript = funscript;
this.debug && console.log(`assignedFunscript = ${funscript.name}`);
// Load and cache funscript
if (!this.funscriptCache.has(funscript.url)) {
const data = await this.loadFunscript(funscript);
console.log('load and cache funscript') // this is never being called by default.
if (data) this.funscriptCache.set(funscript.url, data);
}
// Update UI
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);
});
if (this.debug) {
const actuatorNames = this.menuButtons.map(b => b.options_.title);
console.log("Menu buttons created for actuators:", actuatorNames);
}
}
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);
}
});

View File

@ -0,0 +1 @@
menu selector code copied from https://github.com/chrisboustead/videojs-hls-quality-selector/tree/main

View File

@ -0,0 +1,61 @@
// deviceMap.js
export default class DeviceMap {
constructor() {
// Map of deviceIndex => { name, funscript, index }
this.devices = new Map();
}
// addDevice(device) {
// if (this.devices.has(device.index)) return;
// this.devices.set(device.index, {
// name: device.name,
// funscript: null,
// });
// console.log(`[DeviceMap] Added device "${device.name}", index=${device.index}`);
// }
addDevice(device) {
const scalarCmds = device._deviceInfo?.DeviceMessages?.ScalarCmd || [];
const actuators = scalarCmds.map((cmd, i) => {
return {
name: `${device.name} - ${cmd.ActuatorType} #${cmd.Index + 1}`,
type: cmd.ActuatorType,
index: cmd.Index,
reportedValue: cmd.Default ?? 0, // example, could use other reported info
assignedFunscript: null, // initially empty
};
});
this.devices.push({
name: device._deviceInfo.DeviceName,
index: this.devices.length,
actuators
});
}
removeDevice(device) {
this.devices.delete(device.index);
console.log(`[DeviceMap] Removed device "${device.name}"`);
}
getDevices() {
// Return array for easy menu mapping
return Array.from(this.devices.entries()).map(([index, { name, funscript }]) => ({
index,
name,
funscript,
}));
}
assignFunscript(deviceIndex, funscript) {
const d = this.devices.get(deviceIndex);
if (!d) return;
d.funscript = funscript;
console.log(`[DeviceMap] Assigned funscript to "${d.name}"`);
}
}

View File

@ -0,0 +1,35 @@
export function mapDeviceToFunscriptFeatures(device) {
var mappings = [];
if (!device || typeof device !== "object") return mappings;
var attrs = device.messageAttributes || {};
// Helper to safely iterate
function forEachFeature(arr, actuatorType, funscriptType) {
if (!arr) return;
try {
// coerce to array in case it's undefined or not an array
var features = Array.isArray(arr) ? arr : [];
features.forEach(function (f, i) {
mappings.push({
actuatorIndex: typeof f.Index === "number" ? f.Index : i,
actuatorType: actuatorType,
funscriptType: funscriptType,
stepCount: typeof f.StepCount === "number" ? f.StepCount : 100
});
});
} catch (err) {
console.warn("[buttplug] Failed to process features for", actuatorType, err);
}
}
forEachFeature(attrs.VibrateCmd, "vibrate", "Vibrate");
forEachFeature(attrs.LinearCmd, "linear", "Oscillate");
forEachFeature(attrs.RotateCmd, "rotate", "Rotate");
return mappings;
}

View File

@ -0,0 +1,11 @@
export function collectFunscripts() {
return Array.from(document.querySelectorAll('.funscript'))
.map(div => {
const url = div.getAttribute('data-url');
if (!url) return null;
const name = url.split('/').pop().split('?')[0]; // filename without query
return { name, url };
})
.filter(Boolean); // remove nulls
}

View File

@ -5,6 +5,7 @@
align-items: center;
justify-content: center;
padding: 0;
color: white;
}
.vjs-menu-button.vjs-funscript-selector button::before {
@ -12,7 +13,5 @@
display: block;
width: 1.5em;
height: 1.5em;
background: no-repeat center/contain url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-5 -10 51.480372 52.012249'><path d='m 20.221554,24.21173 3.6797,3.6797 c 0.53906,0.53906 0.73047,1.3281 0.48828,2.058599 l -1.6211,4.878901 c -0.51953,1.558599 -0.30859,3.261698 0.578121,4.6719 0.980469,1.539099 2.952215,2.636214 4.730499,2.5 5.799113,-1.246595 18.520815,-15.042558 18.4025,-18.562601 -0.200697,-2.557237 -2.4414,-5.441399 -5.4414,-5.441399 -0.57813,0 -1.1602,0.08984 -1.7188,0.28125 -1.808761,0.530862 -3.664125,1.462123 -5.48046,1.71874 -0.533759,0.07541 -1.078099,-0.21094 -1.4492,-0.57812 l -3.6797,-3.679701 C 31.639694,10.239 30.889819,4.5008743 26.659194,0.2699988 22.42882,-3.960626 11.331194,-10.000001 3.4091936,-10 c -16.3546746,0.5198707 -4.9007052,25.702135 1.3475999,32.1723 4.7260967,4.893907 9.7749655,4.584497 15.4647605,2.03943 z' fill='currentColor'/></svg>");
color: cyan;
/* SVG fill via currentColor */
background: no-repeat center/contain url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-5 -10 51.480372 52.012249'><path d='m 20.221554,24.21173 3.6797,3.6797 c 0.53906,0.53906 0.73047,1.3281 0.48828,2.058599 l -1.6211,4.878901 c -0.51953,1.558599 -0.30859,3.261698 0.578121,4.6719 0.980469,1.539099 2.952215,2.636214 4.730499,2.5 5.799113,-1.246595 18.520815,-15.042558 18.4025,-18.562601 -0.200697,-2.557237 -2.4414,-5.441399 -5.4414,-5.441399 -0.57813,0 -1.1602,0.08984 -1.7188,0.28125 -1.808761,0.530862 -3.664125,1.462123 -5.48046,1.71874 -0.533759,0.07541 -1.078099,-0.21094 -1.4492,-0.57812 l -3.6797,-3.679701 C 31.639694,10.239 30.889819,4.5008743 26.659194,0.2699988 22.42882,-3.960626 11.331194,-10.000001 3.4091936,-10 c -16.3546746,0.5198707 -4.9007052,25.702135 1.3475999,32.1723 4.7260967,4.893907 9.7749655,4.584497 15.4647605,2.03943 z' fill='white'/></svg>");
}

View File

@ -1,19 +1,33 @@
import videojs from 'video.js'
import 'video.js/dist/video-js.min.css'
// import 'videojs-contrib-quality-levels'
import { download } from './downloader/download.js'
import { collectFunscripts } from './videojs-funscripts/dom.js'
import 'videojs-hls-quality-selector'
import './videojs-buttplug/plugin.js'
import './videojs-funscripts/Funscripts.js'
window.download = download
window.HELP_IMPROVE_VIDEOJS = false; // disable videojs tracking
var player = videojs('#player');
const player = videojs('player');
player.ready(() => {
player.funscripts();
// player.qualityLevels();
// set up plugins
const funscripts = collectFunscripts()
const funscriptsOptions = {
buttplugClientName: "future.porn",
debug: false,
funscripts,
}
player.funscriptPlayer(funscriptsOptions);
player.hlsQualitySelector({
displayCurrentQuality: true,

View File

@ -40,16 +40,16 @@ const scheduleVodProcessing: Task = async (payload: unknown, helpers) => {
const jobs: Promise<Job>[] = [];
jobs.push(helpers.addJob("copyV1S3ToV2", { vodId }));
// if (!vod.sourceVideo) jobs.push(helpers.addJob("getSourceVideo", { vodId }));
// if (!vod.sourceVideoDuration) jobs.push(helpers.addJob("getSourceVideoMetadata", { vodId }))
// if (!vod.sha256sum) jobs.push(helpers.addJob("generateVideoChecksum", { vodId }));
// if (!vod.thumbnail) jobs.push(helpers.addJob("createVideoThumbnail", { vodId }));
// if (!vod.hlsPlaylist) jobs.push(helpers.addJob("createHlsPlaylist", { vodId }));
// if (!vod.cidv1) jobs.push(helpers.addJob("createIpfsCid", { vodId }));
if (!vod.sourceVideo) jobs.push(helpers.addJob("getSourceVideo", { vodId }));
if (!vod.sourceVideoDuration) jobs.push(helpers.addJob("getSourceVideoMetadata", { vodId }))
if (!vod.sha256sum) jobs.push(helpers.addJob("generateVideoChecksum", { vodId }));
if (!vod.thumbnail) jobs.push(helpers.addJob("createVideoThumbnail", { vodId }));
if (!vod.hlsPlaylist) jobs.push(helpers.addJob("createHlsPlaylist", { vodId }));
if (!vod.cidv1) jobs.push(helpers.addJob("createIpfsCid", { vodId }));
if (!vod.funscriptVibrate || !vod.funscriptThrust) jobs.push(helpers.addJob("createFunscript", { vodId }));
// if (!vod.asrVttKey) jobs.push(helpers.addJob("createTranscription", { vodId }));
// if (!vod.slvttVTTKey) jobs.push(helpers.addJob("createStoryboard", { vodId }));
// if (!vod.magnetLink) jobs.push(helpers.addJob("createTorrent", { vodId }));
if (!vod.asrVttKey) jobs.push(helpers.addJob("createTranscription", { vodId }));
if (!vod.slvttVTTKey) jobs.push(helpers.addJob("createStoryboard", { vodId }));
if (!vod.magnetLink) jobs.push(helpers.addJob("createTorrent", { vodId }));
const changes = jobs.length;
if (changes > 0) {

View File

@ -47,7 +47,10 @@
data-setup='{}' data-playlist="{{signedHlsUrl vod.hlsPlaylist}}">
<source src="/hls/{{vod.id}}/master.m3u8" type="application/x-mpegURL">
{{#if (hasRole "supporterTier1" user)}}
<track kind="captions" src="{{getCdnUrl vod.asrVttKey}}" srclang="en" label="English" default>{{/if}}
<track kind="captions" src="{{getCdnUrl vod.asrVttKey}}" srclang="en" label="English" default>
<div class="funscript" data-url="{{getCdnUrl vod.funscriptVibrate}}"></div>
<div class="funscript" data-url="{{getCdnUrl vod.funscriptThrust}}"></div>
{{/if}}
<p class="vjs-no-js">
To view this video please enable JavaScript, and consider upgrading to a
web browser that
@ -372,27 +375,6 @@
async function download(cdnUrl, fileName) {
console.log(`downloading cdnUrl=${cdnUrl} fileName=${fileName}`)
try {
const response = await fetch(cdnUrl);
if (!response.ok) throw new Error('Network response was not ok');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('There was a problem with the fetch operation:', error);
}
}
</script>
@ -457,7 +439,6 @@ Script 4: Initialize the buttplug plugin and pass in the funscript URL from the
</script> --}}
<script type="module" src="/assets/vod.js"></script>