I created a script using Chatgpt and DeepSeek, which can sync funscripts directly to videos on PMVHaven. Still some debugging stuff i didn’t clean up.
it uses the public handyfeeling script host, and is, if it wasnt obvious, only for the handy.
it creates a floating box like this in the top right of all PMVHaven video pages:
It persists the scripts so that every time you open a video you already added a script for, it will load the same script again.
it automatically syncs to timestamps, pauses and starts etc and sets delay so things line up.
i have not tested it extensively, but it seems to work pretty damn well, let me know what you think!
I have only tested it on firefox, i cant say for sure it will work on something like chrome or edge etc.
to use it you just need to install any userscript extension (i recommend https://violentmonkey.github.io/, and then create a new userscript in it, pasting this code. working on providing a better install, but github bein a lil hoe atm:
PMVHaven Funscript Sync
// ==UserScript==
// @name PMVHaven Funscript Sync with IndexedDB
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Sync funscripts with PMVHaven videos using Handy API v2 (HSSP) and IndexedDB
// @author You
// @match https://pmvhaven.com/video/*
// @grant GM_xmlhttpRequest
// @grant GM.getValue
// @grant GM.setValue
// @grant GM_notification
// @grant GM_download
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.5.0/jszip.min.js
// @connect scripts01.handyfeeling.com
// @connect handyfeeling.com
// @connect *
// ==/UserScript==
(function () {
'use strict';
// Expose JSZip globally
window.JSZip = JSZip;
/**********************
* CONFIGURATION
**********************/
const CONFIG = {
DEBUG: true,
NOTIFICATIONS: false,
};
const CONNECTION_KEY_STORAGE = 'handyApiKey';
const HANDY_API_URL = 'https://www.handyfeeling.com/api/handy/v2';
const HOSTING_API_URL = 'https://scripts01.handyfeeling.com/api/hosting/v2';
const TIME_SYNC_SAMPLES = 30;
const MAX_FILE_SIZE = 999999999;
/**********************
* STATE VARIABLES
**********************/
let handyKey = '';
let currentScript = []; // Parsed funscript actions for the current video
let currentScriptRecord = null; // Record from IndexedDB: { videoUrl, fileName, blob, hostingUrl }
let isConnected = false;
let cs_offset = 0; // Client-server offset in ms
let lastUrl = '';
let scriptSetupComplete = false; // Indicates whether the current funscript is set up on the device
let db;
/**********************
* UTILITY FUNCTIONS
**********************/
function log(...args) {
if (CONFIG.DEBUG) {
console.log('[PMVHaven Sync]', ...args);
}
}
function showNotification(title, message) {
if (!CONFIG.NOTIFICATIONS) return;
GM_notification({ title, text: message, timeout: 5000 });
}
/**********************
* FETCH FUNSCRIPT (JSON/CSV)
**********************/
async function fetchFunscript(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch funscript: ' + response.statusText);
}
const contentType = response.headers.get("content-type");
const text = await response.text();
if (contentType && contentType.indexOf("application/json") !== -1) {
try {
const data = JSON.parse(text);
return data.actions || [];
} catch (err) {
throw new Error("JSON parse error: " + err.message);
}
} else {
// Assume CSV format (each line: at,pos)
return text.trim().split("\n").map(line => {
const parts = line.split(',');
if (parts.length < 2) return null;
return { at: Number(parts[0]), pos: Number(parts[1]) };
}).filter(a => a !== null);
}
}
/**********************
* INDEXEDDB SETUP (ONE SCRIPT PER VIDEO)
**********************/
const DB_NAME = 'FunscriptDB';
const DB_VERSION = 1;
const STORE_NAME = 'scripts';
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = (event) => {
log('IndexedDB error:', event.target.errorCode);
reject(event.target.errorCode);
};
request.onsuccess = (event) => {
db = event.target.result;
resolve(db);
};
request.onupgradeneeded = (event) => {
db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'videoUrl' });
}
};
});
}
function saveScriptForVideo(videoUrl, fileName, blob, hostingUrl) {
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const record = { videoUrl, fileName, blob, hostingUrl };
const request = store.put(record);
request.onsuccess = () => resolve();
request.onerror = (e) => reject(e);
});
}
function getScriptForVideo(videoUrl) {
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.get(videoUrl);
request.onsuccess = (event) => resolve(event.target.result);
request.onerror = (e) => reject(e);
});
}
/**********************
* HOSTING API: UPLOAD (multipart/form-data)
**********************/
async function uploadLocalScript(file) {
if (file.size > MAX_FILE_SIZE) {
throw new Error('File too large. Maximum allowed size is ' + MAX_FILE_SIZE + ' bytes.');
}
const payload = await file.text();
log(`Payload length: ${payload.length} characters`);
if (!payload) {
throw new Error('Empty file content');
}
const fileType = file.type || "application/json";
const boundary = "----WebKitFormBoundary" + Math.random().toString(36).substring(2);
const body =
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${file.name}"\r\n` +
`Content-Type: ${fileType}\r\n\r\n` +
`${payload}\r\n` +
`--${boundary}--\r\n`;
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: `${HOSTING_API_URL}/upload`,
data: body,
headers: {
"Content-Type": "multipart/form-data; boundary=" + boundary,
"Accept": "application/json"
},
onload: (response) => {
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
log("Upload successful, hosting URL:", data.url);
resolve(data.url);
} catch (err) {
log("Parsing upload response failed:", err, response.responseText);
reject(err);
}
} else {
log("Upload failed with status:", response.status, response.statusText, response.responseText);
reject(new Error("Upload failed: " + response.statusText));
}
},
onerror: (error) => {
log("GM_xmlhttpRequest error:", error);
reject(error);
}
});
});
}
/**********************
* UI SETUP
**********************/
const uiContainer = document.createElement('div');
uiContainer.style.cssText = `
position: fixed; top: 20px; right: 20px;
background: rgba(0,0,0,0.85); padding: 10px;
color: white; z-index: 9999; font-family: Arial, sans-serif;
width: 320px; border-radius: 8px;
display: flex;
flex-direction: column;
gap: 8px;
`;
uiContainer.innerHTML = `
<div id="device-status" style="display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center;">
<span id="status-dot" style="display:inline-block;width:10px;height:10px;border-radius:50%;background:red;"></span>
<span id="status-text" style="margin-left:8px;">Device Not Connected</span>
</div>
<span id="delay-info" style="font-size:12px; font-style:italic; color:gray;">N/A</span>
</div>
<div id="connection-key" style="display: flex; align-items: center;">
<span style="margin-right:4px;">🔑 Key:</span>
<input id="handy-key" style="border-radius:5px; padding:4px; flex-grow: 1;" placeholder="Enter API key">
</div>
<div id="script-info" style="display: flex; align-items: center;">
<span style="margin-right:4px;">📈</span>
<span id="script-title" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-grow: 1;">None</span>
</div>
<div id="upload-container">
<button id="upload-button" style="width:100%; padding:10px; border-radius:5px; font-size:14px;">⭳ Upload</button>
<input type="file" id="file-input" accept=".json,.csv,.funscript" style="display:none;">
</div>
`;
document.body.appendChild(uiContainer);
/**********************
* UI EVENT HANDLERS
**********************/
document.getElementById('upload-button').addEventListener('click', () => {
document.getElementById('file-input').click();
});
document.getElementById('file-input').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
log('Selected file:', file.name);
const currentVideo = window.location.href;
try {
const hostingUrl = await uploadLocalScript(file);
await saveScriptForVideo(currentVideo, file.name, file, hostingUrl);
showNotification('File Imported', file.name);
await loadCurrentScript();
} catch (err) {
log('Error storing or uploading file:', err);
showNotification('Upload Error', err.message || 'Failed to upload file.');
}
});
document.getElementById('handy-key').addEventListener('change', async (e) => {
handyKey = e.target.value.trim();
if (handyKey) {
try {
await GM.setValue(CONNECTION_KEY_STORAGE, handyKey);
connectHandy();
} catch (error) {
log('Error saving Handy API key:', error);
}
}
});
/**********************
* VIDEO LISTENER FUNCTIONS
**********************/
async function playHSSPListener(event) {
await playHSSP(event.currentTarget);
}
async function stopHSSPListener(event) {
await stopHSSP();
}
function attachVideoListeners(video) {
video.removeEventListener('play', playHSSPListener);
video.removeEventListener('pause', stopHSSPListener);
video.addEventListener('play', playHSSPListener);
video.addEventListener('pause', stopHSSPListener);
}
function reattachVideoListeners() {
const video = document.getElementById('VideoPlayer');
if (video) {
attachVideoListeners(video);
}
}
/**********************
* HANDY API FUNCTIONS
**********************/
async function connectHandy() {
if (!handyKey) {
log('No Handy API key provided');
updateConnectionStatus(false);
return;
}
updateConnectionStatus(null, "Connecting...");
try {
const response = await fetch(`${HANDY_API_URL}/info`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Connection-Key': handyKey
},
credentials: 'include'
});
const responseData = await response.json();
log('Handy info response:', response.status, responseData);
if (response.ok) {
if (typeof responseData.fwStatus !== 'undefined' && responseData.fwStatus !== 0) {
updateConnectionStatus(false, `Firmware Update Required (fwStatus: ${responseData.fwStatus})`);
showNotification('Firmware Update Needed', 'Your device firmware is outdated. Please update your device.');
return;
}
isConnected = true;
updateConnectionStatus(true);
showNotification('Connected', 'Successfully connected to Handy');
await synchronizeTime(TIME_SYNC_SAMPLES);
startPeriodicSync();
if (currentScriptRecord && currentScriptRecord.hostingUrl) {
await setupHSSP(currentScriptRecord.hostingUrl);
}
} else {
const errorMsg = responseData.message || 'Unknown error';
updateConnectionStatus(false, errorMsg);
showNotification('Connection Failed', errorMsg);
}
} catch (error) {
updateConnectionStatus(false, "Connection Error");
showNotification('Connection Error', error.message);
log('Handy connection error:', error);
}
}
function updateConnectionStatus(connected, customText) {
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
if (connected === true) {
statusDot.style.background = "green";
statusText.textContent = customText || "Device Connected";
} else if (connected === false) {
statusDot.style.background = "red";
statusText.textContent = customText || "Device Not Connected";
} else {
statusDot.style.background = "orange";
statusText.textContent = customText || "Connecting...";
}
}
async function setupHSSP(scriptUrl) {
log(`Setting up HSSP with script URL: ${scriptUrl}`);
try {
const response = await fetch(`${HANDY_API_URL}/hssp/setup`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Connection-Key': handyKey
},
body: JSON.stringify({ url: scriptUrl })
});
if (response.ok) {
log('HSSP setup successful.');
scriptSetupComplete = true;
} else {
log('HSSP setup failed with status:', response.status, response.statusText);
scriptSetupComplete = false;
}
} catch (error) {
log('Error in HSSP setup:', error);
scriptSetupComplete = false;
}
}
async function playHSSP(video) {
if (!scriptSetupComplete && currentScriptRecord && currentScriptRecord.hostingUrl) {
await setupHSSP(currentScriptRecord.hostingUrl);
}
const targetTime = Math.floor(video.currentTime * 1000);
const estimatedServerTime = Date.now() + cs_offset;
log(`Calling HSSP play: startTime: ${targetTime}ms, estimatedServerTime: ${estimatedServerTime}`);
try {
const response = await fetch(`${HANDY_API_URL}/hssp/play`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Connection-Key': handyKey
},
body: JSON.stringify({ estimatedServerTime, startTime: targetTime })
});
if (response.ok) {
log('HSSP play command successful.');
} else {
log('HSSP play command failed with status:', response.status, response.statusText);
}
} catch (error) {
log('Error sending HSSP play command:', error);
}
}
async function stopHSSP() {
log('Calling HSSP stop.');
try {
const response = await fetch(`${HANDY_API_URL}/hssp/stop`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Connection-Key': handyKey
}
});
if (response.ok) {
log('HSSP stop command successful.');
} else {
log('HSSP stop command failed with status:', response.status, response.statusText);
}
} catch (error) {
log('Error sending HSSP stop command:', error);
}
}
/**********************
* TIME SYNCHRONIZATION
**********************/
async function synchronizeTime(sampleCount) {
log(`Starting time synchronization with ${sampleCount} samples...`);
let offsetAgg = 0;
for (let i = 0; i < sampleCount; i++) {
const tSend = Date.now();
try {
const response = await fetch(`${HANDY_API_URL}/servertime`, { method: 'GET' });
const tReceive = Date.now();
const data = await response.json();
const ts = data.serverTime;
const rtd = tReceive - tSend;
const ts_est = ts + Math.floor(rtd / 2);
const offset = ts_est - tReceive;
offsetAgg += offset;
log(`Sample ${i + 1}: ts=${ts}, rtd=${rtd}, ts_est=${ts_est}, offset=${offset}`);
} catch (e) {
log('Time sync sample error:', e);
}
}
cs_offset = Math.floor(offsetAgg / sampleCount);
log(`Time synchronization complete. cs_offset=${cs_offset}ms`);
const delayElem = document.getElementById("delay-info");
if (delayElem) {
const sign = cs_offset >= 0 ? "+" : "";
delayElem.textContent = sign + cs_offset + "ms";
}
}
function startPeriodicSync() {
setInterval(() => {
log("Periodic time re-synchronization starting...");
synchronizeTime(TIME_SYNC_SAMPLES);
}, 5 * 60 * 1000);
}
/**********************
* VIDEO ELEMENT HANDLING
**********************/
function waitForVideoElement(callback) {
const interval = setInterval(() => {
const video = document.getElementById('VideoPlayer');
if (video) {
clearInterval(interval);
callback(video);
}
}, 500);
}
/**********************
* SCRIPT LOADING FOR CURRENT VIDEO
**********************/
async function loadCurrentScript() {
const currentVideo = window.location.href;
try {
await openDB();
const record = await getScriptForVideo(currentVideo);
currentScriptRecord = record;
const scriptTitleElem = document.getElementById('script-title');
scriptSetupComplete = false;
if (record) {
scriptTitleElem.textContent = record.fileName;
try {
currentScript = await fetchFunscript(record.hostingUrl);
log(`Loaded funscript: ${record.fileName} with ${currentScript.length} actions`);
if (isConnected) await setupHSSP(record.hostingUrl);
} catch (err) {
log('Error fetching funscript from hosting URL:', err);
scriptTitleElem.textContent = "Error loading script";
}
} else {
scriptTitleElem.textContent = "None";
}
} catch (err) {
log('Error loading script for current video:', err);
}
}
/**********************
* URL CHANGE MONITORING
**********************/
function monitorUrlChange() {
lastUrl = window.location.href;
setInterval(() => {
if (window.location.href !== lastUrl) {
lastUrl = window.location.href;
log("URL changed to:", lastUrl);
if (handyKey) {
connectHandy();
}
loadCurrentScript();
reattachVideoListeners();
}
}, 1000);
}
/**********************
* BACKUP / RESTORE UI
**********************/
const backupContainer = document.createElement('div');
backupContainer.innerHTML = `
<details>
<summary style="cursor: pointer;">🛠 Backup / Restore</summary>
<button id="export-button" style="margin-top: 6px;">⬇ Export Backup</button>
<button id="import-button">⬆ Import Backup</button>
<input type="file" id="import-file" accept=".zip" style="display:none;">
</details>
`;
uiContainer.appendChild(backupContainer);
async function exportDatabaseZip() {
try {
log("Export triggered");
await openDB();
const zip = new JSZip();
const folder = zip.folder('funscripts');
const manifest = [];
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.getAll();
const allRecords = await new Promise((res, rej) => {
request.onsuccess = () => res(request.result);
request.onerror = (e) => rej(e);
});
log("Found", allRecords.length, "records in DB");
for (const record of allRecords) {
log("Processing record for:", record.fileName);
const { videoUrl, fileName, hostingUrl, blob } = record;
const text = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsText(blob);
});
const safeName = `${videoUrl.replace(/[^\w.-]/g, '_')}__${fileName}`;
folder.file(safeName, text);
manifest.push({
videoUrl,
fileName,
hostingUrl,
blobName: safeName,
blobType: blob.type
});
}
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
// Small delay before ZIP generation
await new Promise(resolve => setTimeout(resolve, 100));
try {
const generatedBlob = await zip.generateAsync({
type: 'blob',
compression: "STORE",
worker: false,
streamFiles: true,
createFolders: true
});
const url = URL.createObjectURL(generatedBlob);
if (typeof GM_download === 'function') {
GM_download({
url: url,
name: 'pmvhaven-funscript-backup.zip'
});
setTimeout(() => {
URL.revokeObjectURL(url);
}, 1000);
} else {
const a = document.createElement('a');
a.href = url;
a.download = 'pmvhaven-funscript-backup.zip';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 1000);
}
showNotification('Export Complete', 'Backup ZIP downloaded.');
} catch (err) {
log("ZIP generation error:", err);
}
} catch (err) {
log("Export error:", err);
}
}
async function importDatabaseZip(file) {
const zip = await JSZip.loadAsync(file);
const manifestText = await zip.file('manifest.json').async('string');
const manifest = JSON.parse(manifestText);
await openDB();
// Process each record separately
for (const entry of manifest) {
const content = await zip.file(`funscripts/${entry.blobName}`).async('string');
const blob = new Blob([content], { type: entry.blobType });
const record = {
videoUrl: entry.videoUrl,
fileName: entry.fileName,
blob,
hostingUrl: entry.hostingUrl
};
await new Promise((resolve, reject) => {
const tx = db.transaction([STORE_NAME], "readwrite");
const store = tx.objectStore(STORE_NAME);
const request = store.put(record);
request.onsuccess = () => resolve();
request.onerror = (e) => reject(e);
});
}
showNotification('Import Complete', 'Backup restored from ZIP.');
loadCurrentScript();
}
document.getElementById('export-button').addEventListener('click', () => {
exportDatabaseZip().catch(err => {
log('ZIP export failed:', err);
showNotification('Export Error', err.message);
});
});
document.getElementById('import-button').addEventListener('click', () => {
document.getElementById('import-file').click();
});
document.getElementById('import-file').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
await importDatabaseZip(file);
} catch (err) {
log('ZIP import failed:', err);
showNotification('Import Error', err.message);
}
});
/**********************
* INITIALIZATION
**********************/
async function init() {
log('Initializing PMVHaven Sync (Indexed Version)');
try {
const storedKey = await GM.getValue(CONNECTION_KEY_STORAGE, '');
if (storedKey) {
handyKey = storedKey;
document.getElementById('handy-key').value = storedKey;
}
} catch (e) {
log('Error loading Handy API key:', e);
}
if (handyKey) {
connectHandy();
}
loadCurrentScript();
waitForVideoElement(attachVideoListeners);
monitorUrlChange();
}
init();
})();
if you want to export everything you have (in case you are switching browsers for any reason) you can click export, and it’ll download a full .zip file containing all your scripts, so you can easily import them again.
making a list of things that need fixing here for now:
- If your device has a script loaded and you play a video that does not have a script, it wont flush the script so it ends up using the wrong script entirely (annoying for previewing videos)
- Often have to refresh the webpage for the script to properly update after uploading.
- UI is functional but pretty fugly.