How the Funscript Player is built: a Node.js server without dependencies and a static interface made of plain scripts – no build step, no framework. Everything that drives the devices runs in the browser.
Contents: Architecture · Files · Core concepts · HTTP API · Configuration · Working on the code · Tests
The server delivers the interface, streams videos, proxies Stash and checks itself at startup. The browser talks to the Handy (cloud) and the Satisfyer (Bluetooth) directly.
Browser (public/*.js) server.js + lib/
player, sync, library, devices, <──> HTTP(S), range streaming, Stash proxy,
intensity; localStorage per device HereSphere, HQ (ffmpeg), self-check, data/*.json
│ │ │ │ │ │
Handy cloud Satisfyer Intiface Central Stash /media ffmpeg / VAAPI
(API v2: (Web (ws:// in the LAN, (GraphQL (videos + (HQ stream,
HSSP, clock, Bluetooth) forwarded via + files) .funscript) Intel graphics)
upload) /intiface)
Since 2.0 the scripts in public/ are ES modules (no build step). main.js is the only <script type="module"> and starts
the interface (no inline scripts because of the Content Security Policy).
Modules do no work at load time: each exports an init(), and main.js calls them in a fixed order. state.js holds
shared state and modulesReady (which init() has already run – replaces the old typeof x === 'function' guards).
| File | Purpose |
|---|---|
server.js |
Routes table (ROUTES), login, security headers incl. CSP, origin check, Intiface WebSocket forwarding, startup summary and slow requests in docker logs (English; the release workflow greps for ✓ ready and Self-test: n/n) |
lib/config.js · lib/util.js |
Environment variables and constants · safe paths (safeJoin), readBody, sendFile with range, cleanTitle |
lib/library.js |
Folder library, cached; rescanned when the folders change (librarySignature) or in the background after 30 s |
lib/settings.js |
Server settings (HereSphere, scrypt password hash and signed token, Intiface home-network only) |
lib/data.js |
Shared settings, intensity, own scripts (data/userscripts.json), Handy upload cache, patterns |
lib/stash.js · lib/heresphere.js |
Stash GraphQL, filters, write-back, proxy · HereSphere API (VR only, password, cached list) |
lib/hq.js |
HQ stream via ffmpeg (ffprobe results cached); presets natural (auto), clean, strong |
lib/selfcheck.js · lib/update.js |
Startup self-check (data folder, stored files, every UI script compiles, every $('id') exists in index.html, script intensity) – result in docker logs, also used by the tests · “update available” (latest GitHub release, cached 6 h) |
lang.js · lang/en.js · lang/de.js |
Language since 2.0: texts by key (t('nav.library'), data-i18n="…"), English is the base, German the translation; test/lang.test.js checks both files match and every key is used |
handy.js |
Class Handy (API v2/v3: HAMP, HSSP with playback_rate, clock sync, upload cache), shared settings (SHARED_PREFS, synced with /api/prefs), claimHandy(), helpers $, store, log, pillText, positionAt |
app.js |
Modes and pages, menu, device window, “devices follow the video”, loading video + script, sync video → Handy (doSync), heatmap (paintHeat), markers |
library.js |
Library (folders + Stash), filters, opening a scene, Stash actions (sceneAct: ★, favourite, O, marker), resume position, sprites |
control.js |
Control mode: direct control, pattern editor, emergencyStop() |
vibe.js |
Satisfyer via Web Bluetooth and Intiface: several devices, each with its own loop, lead (devLeadMs), strength (devGain), motor mode (same/split), soft start and latency display |
fullscreen.js · gestures.js |
Player bar, fullscreen (native video), taps/double taps/hold, keyboard, STOP button, auto-stop, wake lock · swipe up/down per half in fullscreen (swipeLeft/swipeRight: volume, vibeScene, slideMax, both) on the video element (native fullscreen) and #ovHit |
enhance.js · hq.js |
Picture enhancement via WebGL · HQ stream from the server (seeking restarts the conversion) |
finale.js · tools.js |
Finale · script intensity for both devices: original/auto/soft/intense (processScript, multiplyCalm, presetVibeShape), per scene, previews, offset calibration |
stroker.js |
Stroker devices via Intiface (e.g. the Handy over Bluetooth) |
audiovibe.js (removed in 1.19.9) |
🧪 “Follow sound” (beta): video → Web Audio AnalyserNode; audioVibeLevel() gives targetLevel() a 0–1 level from loudness above the running average plus onsets; audioHandySpeed() smooths it (~0.8 s) into Handy tempo steps of 10 for selfHampLevel(). The AudioContext is only created on a click (otherwise the video would play muted); all media is same-origin, so the analyser gets real samples |
selfctl.js · touchpad.js |
“Control yourself” (selfCtl.mode: off / self) for videos without a Funscript · touch area (tempo or 🧪 position via HDSP, at most 2 commands in flight, slower on HTTP 429), ⏺ recording |
settings.js |
Settings page: collapsible sections, ⓘ explanations, picture/HQ, player display, update notice, “new in” dialog, welcome dialog (only on a fresh install: freshInstall from handy.js = nothing stored in the browser nor on the server), install as app (beforeinstallprompt, otherwise a hint) and “whole app in fullscreen” (appFs, next tap), reset |
selftest.js |
Developer self-test in the browser: append ?selbsttest to the address – runs typical flows with a simulated Handy |
test/ |
Automated tests with Node built-ins: server, script logic, lang.test.js (keys complete in en/de, none unused), browser.test.js (runs ?selbsttest in headless Chrome via the DevTools protocol, fixtures in test/fixtures/; skipped locally without Chrome, required on GitHub) |
eslint.config.js |
ESLint, only on GitHub (npm ci && npx eslint ., versions pinned in package-lock.json, updated by Dependabot). All top-level names of the UI scripts are registered as shared globals, so typos are caught without flagging cross-file names |
| Health check | checkDevices() in app.js (on return after > 20 s and every minute): Handy /connected, Satisfyer checkVibes() → dev.lost() (one reconnect at a time); deviceLost() pauses the video if enabled (pauseOnLoss) |
Timeline. mediaTime(), seekTo(), duration() and videoPlaying() are the single truth about the video. They
account for the HQ stream (which starts at 0 after every seek → hq.offset). Never use video.currentTime or
video.paused directly for device logic.
Who controls the Handy? handy.owner is 'video', 'manual', 'self', 'pattern' or 'finale'.
claimHandy(owner) switches and fires the handy-owner event – the other parts reset themselves (e.g. the video
pauses).
Script chain. script.actions → toolActions() (intensity: analysis + adjustment in tools.js, per scene in
scenePresets) → sendActions() (finale “half tempo”) → Handy (CSV via HSSP) or Satisfyer. The heatmap shows
toolActions().
Mirrored controls. Fields that appear twice (connection key / Application ID in the device window, Satisfyer
strength min/max and translation at the video) are mirrors of the settings fields: mirrorField(mirror, src) in
app.js copies the value and re-dispatches input/change on the original, so only one place stores and reacts.
Vibration. scriptSource() returns { actions, ms } from control or video. Each Satisfyer has its own loop:
targetLevel() computes a strength 0–1, it is smoothed and only sent on change, ahead by the device's own Bluetooth
latency. Emergency stop and pause set 0 immediately; after quiet phases the level ramps up within 0.5 s.
Handy sync. doSync() preloads the script (handy.prepare; uploads remembered in the browser and on the server via
/api/handycache, expired URL → upload once more) and starts with handy.play(mediaTime()·1000 + offset). Video
events trigger resync(); every 30 s it silently corrects, every 5 min the clock is re-synced. Seeking or buffering
stops the devices until the video plays again.
Script intensity. currentPreset() takes the scene's choice (scenePresets[offsetKey()]) or the default
(scriptIntensity). analyzeScript() measures stroke (without the outer 2 %), jitter and tempo spread;
processScript() applies: remove jitter < 3 % → stretch the stroke → fitHandy() (halveFast() / limitSpeed(),
limit handyMaxSpeed() by model). Results are cached per script.
Only connected devices. updateDevClasses() sets has-handy, has-vibe, has-dev, dev-idle on body every
0.5 s; CSS shows or hides sliders, bar buttons and touch options accordingly.
Script per device. processScript(actions, 'handy' | 'vibe'): shared steps (jitter, stroke), then speed limit
for the Handy only, smoothing for the Satisfyer. The offset applies to both.
Own scripts & recording. saveScriptToLibrary() → POST /api/userscript: next to the folder video or in
data/scripts/, listed in data/userscripts.json – only those may be replaced or deleted, originals never.
Storing settings. localStorage via store.get/set (prefix hp.); keys in SHARED_PREFS also on the server
(/api/prefs, for all devices – the server is authoritative: what's missing there is deleted in the browser).
Advanced settings: class .adv, shown with body.adv-on.
All responses are JSON except file streams. With AUTH_USER/AUTH_PASS, basic auth applies to everything except
/healthz and /api/version. Changes (everything except GET/HEAD, except /heresphere) only from the player's own page
(Sec-Fetch-Site / Origin, otherwise 403). Headers: Content Security Policy, X-Frame-Options, nosniff,
Referrer-Policy: same-origin.
| Method | Path | Purpose |
|---|---|---|
| GET | /healthz · /api/version |
Docker health check · version |
| GET | /api/library |
Videos under /media with their Funscript |
| GET | /media/<path> |
File from /media with range support |
| GET PUT | /api/store |
Patterns |
| GET | /api/stash/status · saved-filters · filters |
Stash reachable? · saved filters · tags/performers/studios |
| GET | /api/stash/scenes?… |
Scene list (search, sort, filter, pages) |
| GET | /api/stash/scene/<id> |
Scene details incl. markers, rating, O, position |
| POST | /api/stash/scene/<id>/rating · favorite · o · o-undo · marker · activity · play |
Write back to Stash |
| GET | /stash/scene/<id>/stream · funscript · screenshot · preview · webp · vtt/… |
Proxied Stash files (only these) |
| GET | /hq/stream?src=…&t=…&preset=…&h=… |
HQ stream from time t (ffmpeg, VAAPI or CPU) |
| GET | /api/hq/status · probe · bench |
HQ available? · resolution/format · benchmark |
| GET POST | /heresphere · /heresphere/stash/<id> · /heresphere/media/… |
HereSphere API (rating/favourite in POST), VR only if hsVrOnly |
| POST | /heresphere/auth |
With a HereSphere password: { username, password } → { "auth-token", access }; the token has no expiry and is bound to the password hash (new password = all tokens invalid) |
| WS | /intiface?target=IP:PORT |
WebSocket forwarding to Intiface: private IPv4 only (home network only by default), allowed ports only (INTIFACE_PORTS), own page only |
| GET PUT | /api/handycache?k=… |
Cache of already uploaded Handy scripts |
| GET PUT | /api/prefs |
Shared settings of all devices; PUT merges, null deletes |
| GET POST DELETE | /api/userscript?src=… |
Own script: save, read, delete (own ones only) |
| GET | /api/userscripts |
List of own scripts |
| GET | /api/update |
{ current, latest, url, available } |
| POST | /api/clientcheck |
Silent self-check of the interface and browser errors → docker logs (max. 20/min) |
| GET | /api/heat?src=… |
Script intensity in 48 sections (for the tiles) |
| GET PUT | /api/settings |
Server settings { heresphere, hsVrOnly, hsPassword, intifaceLan } in data/settings.json |
With Docker all values live in .env (template .env.example, required values on top, the rest commented out); docker-compose.yml passes the whole file to the container (env_file) and only uses a few values itself (port, image version, MEDIA, user, memory).
| Variable | Default | Meaning |
|---|---|---|
PORT · HOST |
8080 · 0.0.0.0 | Where the server listens |
MEDIA_DIR · DATA_DIR |
/media · ./data | Video folder (each subfolder = a source) · player data |
LOG_LEVEL |
– | debug = every request in docker logs (otherwise only slow ones > 1 s) |
STASH_URL · STASH_API_KEY · STASH_INSECURE |
– | Stash address, API key, 1 = accept a self-signed certificate |
STASH_FAVORITE_TAG · STASH_MARKER_TAG |
Favorit · Lieblingsstelle | Tags for favourites and markers |
HQ_ACCEL · HQ_DEVICE · HQ_MAX |
auto · /dev/dri/renderD128 · 2 | vaapi or cpu; graphics device; parallel HQ conversions |
PLAYER_MEM |
2g | Memory limit of the container |
HERESPHERE |
on | off = HereSphere off for good |
INTIFACE_LAN_ONLY · INTIFACE_PORTS |
– · 12345 | 1 = home network only, fixed · allowed Intiface ports (comma-separated) |
AUTH_USER · AUTH_PASS |
– | Optional basic auth |
TLS_PFX (+_PASS) / TLS_CERT + TLS_KEY |
– | HTTPS directly, without a reverse proxy |
FFMPEG · FFPROBE |
ffmpeg · ffprobe | Paths to the programs |
The container runs read-only with /tmp as tmpfs, cap_drop: ALL and no-new-privileges; it only writes to the data
volume.
MEDIA_DIR=./testmedia DATA_DIR=./data STASH_URL=http://stash:9999 node server.js # → http://localhost:8080Without Stash everything works with the folders. Web Bluetooth works on localhost without HTTPS.
- No dependencies: only the Node standard library and browser APIs. No build, no bundler.
- Language: code comments in German (they explain the why). The interface is bilingual: new texts get a key in
public/lang/en.js+public/lang/de.js. All texts use keys (t('key'),data-i18n="key"). - Safety first: everything that moves devices must be stoppable via
emergencyStop()and stop when the connection is lost. - Versioning (from 2.0.0):
MAJOR.MINOR.PATCH– major for big breaks (new interface, renamed container/volume), minor for a batch of features, patch only for bug fixes. Collect changes into one release instead of one per idea. - Test builds without a release: every push to
mainwith green tests buildsghcr.io/scripthost/funscript-player:edge(workflow “Tests”). Point a test server at:edgeto try things;latestand version tags only come from a release. - Release:
VERSION,CHANGELOG.md(English, a## x.y.zsection), README; tagvX.Y.Z→ the workflow runs the tests, starts the container read-only, then publishes the release (ZIP + notes fromCHANGELOG.md) and the Docker image (:x.y.zand:latest).
No extra packages, Node built-ins only – they also run on every push:
node --test "test/*.test.js"test/scripts.test.js– reading Funscripts, script automation, touch recording, intensity, beat guard, half tempo, the four intensities, model detection. Functions are read straight from the browser files (loadFunctionsintest/helpers.js).test/server.test.js– startsserver.jswith test folders: library, shared settings, own scripts, startup self-check, update comparison, HereSphere switch/VR only/password, origin check and security headers, script cache.