funscript playback ui basic actuator menu list
This commit is contained in:
		
							parent
							
								
									e0e10fd926
								
							
						
					
					
						commit
						28f8b7e94e
					
				@ -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"}]}
 | 
				
			||||||
@ -20,8 +20,7 @@ echo("  * Remote pin the IPFS CID")
 | 
				
			|||||||
echo("  [Press Enter When Complete...]")
 | 
					echo("  [Press Enter When Complete...]")
 | 
				
			||||||
_ = stdin()
 | 
					_ = 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: `imdl torrent create --link ~/Documents/voddo/projektmelody-fansly-2025-08-27.mp4`")
 | 
				
			||||||
echo("  * Create magnet link. ex: `torf ~/Downloads/projektmelody-chaturbate-2025-08-27.mp4 --notorrent --notracker`")
 | 
					 | 
				
			||||||
echo("  [Press Enter When Complete...]")
 | 
					echo("  [Press Enter When Complete...]")
 | 
				
			||||||
_ = stdin()
 | 
					_ = stdin()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -39,6 +39,9 @@ ENV LD_LIBRARY_PATH="/app/whisper.cpp/build/src:/app/whisper.cpp/build/ggml/src:
 | 
				
			|||||||
# Install b2-cli
 | 
					# 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
 | 
					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 and install dependencies
 | 
				
			||||||
COPY package.json package-lock.json ./
 | 
					COPY package.json package-lock.json ./
 | 
				
			||||||
RUN npm install --ignore-scripts=false --foreground-scripts --verbose
 | 
					RUN npm install --ignore-scripts=false --foreground-scripts --verbose
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										57
									
								
								services/our/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										57
									
								
								services/our/package-lock.json
									
									
									
										generated
									
									
									
								
							@ -70,6 +70,7 @@
 | 
				
			|||||||
			"devDependencies": {
 | 
								"devDependencies": {
 | 
				
			||||||
				"@eslint/compat": "^1.3.1",
 | 
									"@eslint/compat": "^1.3.1",
 | 
				
			||||||
				"@eslint/js": "^9.31.0",
 | 
									"@eslint/js": "^9.31.0",
 | 
				
			||||||
 | 
									"buttplug": "^3.2.2",
 | 
				
			||||||
				"chokidar": "^4.0.3",
 | 
									"chokidar": "^4.0.3",
 | 
				
			||||||
				"esbuild": "^0.25.9",
 | 
									"esbuild": "^0.25.9",
 | 
				
			||||||
				"esbuild-copy-static-files": "^0.1.0",
 | 
									"esbuild-copy-static-files": "^0.1.0",
 | 
				
			||||||
@ -5527,6 +5528,19 @@
 | 
				
			|||||||
				"esbuild": ">=0.18"
 | 
									"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": {
 | 
							"node_modules/cac": {
 | 
				
			||||||
			"version": "6.7.14",
 | 
								"version": "6.7.14",
 | 
				
			||||||
			"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
 | 
								"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
 | 
				
			||||||
@ -5956,6 +5970,13 @@
 | 
				
			|||||||
				"node": ">=18"
 | 
									"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": {
 | 
							"node_modules/cliui": {
 | 
				
			||||||
			"version": "9.0.1",
 | 
								"version": "9.0.1",
 | 
				
			||||||
			"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
 | 
								"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
 | 
				
			||||||
@ -7057,6 +7078,13 @@
 | 
				
			|||||||
				"node": ">=0.10.0"
 | 
									"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": {
 | 
							"node_modules/execa": {
 | 
				
			||||||
			"version": "5.1.1",
 | 
								"version": "5.1.1",
 | 
				
			||||||
			"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
 | 
								"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
 | 
				
			||||||
@ -10269,6 +10297,13 @@
 | 
				
			|||||||
				"node": ">= 12.13.0"
 | 
									"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": {
 | 
							"node_modules/require-addon": {
 | 
				
			||||||
			"version": "1.1.0",
 | 
								"version": "1.1.0",
 | 
				
			||||||
			"resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.1.0.tgz",
 | 
								"resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.1.0.tgz",
 | 
				
			||||||
@ -12258,6 +12293,28 @@
 | 
				
			|||||||
			"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
 | 
								"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
 | 
				
			||||||
			"license": "ISC"
 | 
								"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": {
 | 
							"node_modules/xtend": {
 | 
				
			||||||
			"version": "4.0.2",
 | 
								"version": "4.0.2",
 | 
				
			||||||
			"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
 | 
								"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
 | 
				
			||||||
 | 
				
			|||||||
@ -26,6 +26,7 @@
 | 
				
			|||||||
	"devDependencies": {
 | 
						"devDependencies": {
 | 
				
			||||||
		"@eslint/compat": "^1.3.1",
 | 
							"@eslint/compat": "^1.3.1",
 | 
				
			||||||
		"@eslint/js": "^9.31.0",
 | 
							"@eslint/js": "^9.31.0",
 | 
				
			||||||
 | 
							"buttplug": "^3.2.2",
 | 
				
			||||||
		"chokidar": "^4.0.3",
 | 
							"chokidar": "^4.0.3",
 | 
				
			||||||
		"esbuild": "^0.25.9",
 | 
							"esbuild": "^0.25.9",
 | 
				
			||||||
		"esbuild-copy-static-files": "^0.1.0",
 | 
							"esbuild-copy-static-files": "^0.1.0",
 | 
				
			||||||
@ -110,4 +111,4 @@
 | 
				
			|||||||
	"prisma": {
 | 
						"prisma": {
 | 
				
			||||||
		"seed": "tsx prisma/seed.ts"
 | 
							"seed": "tsx prisma/seed.ts"
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -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>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
--}}
 | 
					 | 
				
			||||||
							
								
								
									
										31
									
								
								services/our/src/client/downloader/download.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								services/our/src/client/downloader/download.js
									
									
									
									
									
										Normal 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);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -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;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -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);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1 +0,0 @@
 | 
				
			|||||||
inspired by https://github.com/chrisboustead/videojs-hls-quality-selector/tree/main
 | 
					 | 
				
			||||||
@ -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;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
							
								
								
									
										353
									
								
								services/our/src/client/videojs-funscripts/Funscripts.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										353
									
								
								services/our/src/client/videojs-funscripts/Funscripts.js
									
									
									
									
									
										Normal 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 they’re 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 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; // 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 don’t 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);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										1
									
								
								services/our/src/client/videojs-funscripts/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								services/our/src/client/videojs-funscripts/README.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					menu selector code copied from https://github.com/chrisboustead/videojs-hls-quality-selector/tree/main
 | 
				
			||||||
							
								
								
									
										61
									
								
								services/our/src/client/videojs-funscripts/deviceMap.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								services/our/src/client/videojs-funscripts/deviceMap.js
									
									
									
									
									
										Normal 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}"`);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										35
									
								
								services/our/src/client/videojs-funscripts/devices.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								services/our/src/client/videojs-funscripts/devices.js
									
									
									
									
									
										Normal 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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										11
									
								
								services/our/src/client/videojs-funscripts/dom.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								services/our/src/client/videojs-funscripts/dom.js
									
									
									
									
									
										Normal 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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -5,6 +5,7 @@
 | 
				
			|||||||
  align-items: center;
 | 
					  align-items: center;
 | 
				
			||||||
  justify-content: center;
 | 
					  justify-content: center;
 | 
				
			||||||
  padding: 0;
 | 
					  padding: 0;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.vjs-menu-button.vjs-funscript-selector button::before {
 | 
					.vjs-menu-button.vjs-funscript-selector button::before {
 | 
				
			||||||
@ -12,7 +13,5 @@
 | 
				
			|||||||
  display: block;
 | 
					  display: block;
 | 
				
			||||||
  width: 1.5em;
 | 
					  width: 1.5em;
 | 
				
			||||||
  height: 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>");
 | 
					  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>");
 | 
				
			||||||
  color: cyan;
 | 
					 | 
				
			||||||
  /* SVG fill via currentColor */
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -1,19 +1,33 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import videojs from 'video.js'
 | 
					import videojs from 'video.js'
 | 
				
			||||||
import 'video.js/dist/video-js.min.css'
 | 
					import 'video.js/dist/video-js.min.css'
 | 
				
			||||||
 | 
					import { download } from './downloader/download.js'
 | 
				
			||||||
// import 'videojs-contrib-quality-levels'
 | 
					import { collectFunscripts } from './videojs-funscripts/dom.js'
 | 
				
			||||||
import 'videojs-hls-quality-selector'
 | 
					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
 | 
					window.HELP_IMPROVE_VIDEOJS = false; // disable videojs tracking
 | 
				
			||||||
 | 
					
 | 
				
			||||||
var player = videojs('#player');
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const player = videojs('player');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
player.ready(() => {
 | 
					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({
 | 
					  player.hlsQualitySelector({
 | 
				
			||||||
    displayCurrentQuality: true,
 | 
					    displayCurrentQuality: true,
 | 
				
			||||||
 | 
				
			|||||||
@ -40,16 +40,16 @@ const scheduleVodProcessing: Task = async (payload: unknown, helpers) => {
 | 
				
			|||||||
  const jobs: Promise<Job>[] = [];
 | 
					  const jobs: Promise<Job>[] = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  jobs.push(helpers.addJob("copyV1S3ToV2", { vodId }));
 | 
					  jobs.push(helpers.addJob("copyV1S3ToV2", { vodId }));
 | 
				
			||||||
  // if (!vod.sourceVideo) jobs.push(helpers.addJob("getSourceVideo", { vodId }));
 | 
					  if (!vod.sourceVideo) jobs.push(helpers.addJob("getSourceVideo", { vodId }));
 | 
				
			||||||
  // if (!vod.sourceVideoDuration) jobs.push(helpers.addJob("getSourceVideoMetadata", { vodId }))
 | 
					  if (!vod.sourceVideoDuration) jobs.push(helpers.addJob("getSourceVideoMetadata", { vodId }))
 | 
				
			||||||
  // if (!vod.sha256sum) jobs.push(helpers.addJob("generateVideoChecksum", { vodId }));
 | 
					  if (!vod.sha256sum) jobs.push(helpers.addJob("generateVideoChecksum", { vodId }));
 | 
				
			||||||
  // if (!vod.thumbnail) jobs.push(helpers.addJob("createVideoThumbnail", { vodId }));
 | 
					  if (!vod.thumbnail) jobs.push(helpers.addJob("createVideoThumbnail", { vodId }));
 | 
				
			||||||
  // if (!vod.hlsPlaylist) jobs.push(helpers.addJob("createHlsPlaylist", { vodId }));
 | 
					  if (!vod.hlsPlaylist) jobs.push(helpers.addJob("createHlsPlaylist", { vodId }));
 | 
				
			||||||
  // if (!vod.cidv1) jobs.push(helpers.addJob("createIpfsCid", { vodId }));
 | 
					  if (!vod.cidv1) jobs.push(helpers.addJob("createIpfsCid", { vodId }));
 | 
				
			||||||
  if (!vod.funscriptVibrate || !vod.funscriptThrust) jobs.push(helpers.addJob("createFunscript", { vodId }));
 | 
					  if (!vod.funscriptVibrate || !vod.funscriptThrust) jobs.push(helpers.addJob("createFunscript", { vodId }));
 | 
				
			||||||
  // if (!vod.asrVttKey) jobs.push(helpers.addJob("createTranscription", { vodId }));
 | 
					  if (!vod.asrVttKey) jobs.push(helpers.addJob("createTranscription", { vodId }));
 | 
				
			||||||
  // if (!vod.slvttVTTKey) jobs.push(helpers.addJob("createStoryboard", { vodId }));
 | 
					  if (!vod.slvttVTTKey) jobs.push(helpers.addJob("createStoryboard", { vodId }));
 | 
				
			||||||
  // if (!vod.magnetLink) jobs.push(helpers.addJob("createTorrent", { vodId }));
 | 
					  if (!vod.magnetLink) jobs.push(helpers.addJob("createTorrent", { vodId }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const changes = jobs.length;
 | 
					  const changes = jobs.length;
 | 
				
			||||||
  if (changes > 0) {
 | 
					  if (changes > 0) {
 | 
				
			||||||
 | 
				
			|||||||
@ -47,7 +47,10 @@
 | 
				
			|||||||
        data-setup='{}' data-playlist="{{signedHlsUrl vod.hlsPlaylist}}">
 | 
					        data-setup='{}' data-playlist="{{signedHlsUrl vod.hlsPlaylist}}">
 | 
				
			||||||
        <source src="/hls/{{vod.id}}/master.m3u8" type="application/x-mpegURL">
 | 
					        <source src="/hls/{{vod.id}}/master.m3u8" type="application/x-mpegURL">
 | 
				
			||||||
        {{#if (hasRole "supporterTier1" user)}}
 | 
					        {{#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">
 | 
					        <p class="vjs-no-js">
 | 
				
			||||||
          To view this video please enable JavaScript, and consider upgrading to a
 | 
					          To view this video please enable JavaScript, and consider upgrading to a
 | 
				
			||||||
          web browser that
 | 
					          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>
 | 
					</script>
 | 
				
			||||||
@ -457,7 +439,6 @@ Script 4: Initialize the buttplug plugin and pass in the funscript URL from the
 | 
				
			|||||||
</script> --}}
 | 
					</script> --}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
<script type="module" src="/assets/vod.js"></script>
 | 
					<script type="module" src="/assets/vod.js"></script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user