Funscript Player
Entwickler-Doku

Wie der Funscript Player gebaut ist

Ein Node.js-Server ohne Abhängigkeiten und eine statische Oberfläche aus einfachen Skripten – kein Build-Schritt, kein Framework. Alles, was die Geräte steuert, läuft im Browser.

Aufbau

Der Server liefert die Oberfläche aus, streamt Videos, reicht Stash durch und vermittelt die Fernbedienung. Handy (Cloud) und Satisfyer (Bluetooth) spricht der Browser direkt an.

Browser public/*.js – Player, Sync, Bibliothek, Geräte, Fernbedienung localStorage: Einstellungen pro Gerät (Präfix „hp.“) server.js HTTP(S), Range-Streaming Stash-Proxy, HereSphere HQ (ffmpeg), SSE-Relais data/store.json Stash GraphQL + Dateien /media Videos + .funscript ffmpeg / VAAPI HQ-Stream, Intel-Grafik Intiface Central ws:// im LAN (Weiterleitung) Handy-Cloud (API v2) HSSP, Uhr, Script-Upload Satisfyer Web Bluetooth (GATT)

Dateien

Die Skripte in public/ sind klassische <script>-Dateien ohne Module. Sie teilen sich globale Funktionen und werden in dieser Reihenfolge geladen – spätere dürfen frühere benutzen, umgekehrt nur zur Laufzeit (mit typeof x === 'function' absichern).

DateiAufgabe
server.jsAnfragen verteilen: Anmeldung, Schutz-Header, Herkunftsprüfung, Routen, Intiface-WebSocket-Weiterleitung, Start-Übersicht und langsame Anfragen in docker logs.
lib/config.js · lib/util.jsUmgebungsvariablen und feste Werte · sichere Pfade (safeJoin), readBody, sendFile mit Range, cleanTitle.
lib/library.jsOrdner-Bibliothek, gemerkt; neu eingelesen, wenn sich die Ordner ändern (librarySignature) bzw. im Hintergrund nach 30 s.
lib/settings.js · lib/data.jsServer-Einstellungen (HereSphere, Passwort-Hash, Intiface) · gemeinsame Einstellungen, Intensität, eigene Scripts (data/userscripts.json), Handy-Merkliste, Sicherung, Patterns.
lib/stash.js · lib/heresphere.jsStash-GraphQL, Filter, Zurückschreiben, Proxy · HereSphere-API (nur VR, Passwort, Liste gemerkt).
lib/hq.js · lib/remote.jsHQ-Stream über ffmpeg (ffprobe-Ergebnisse gemerkt) · Fernbedienungs-Relais (Server-Sent Events).
i18n.js · i18n-en.jsSprache: Englisch per Tabelle (deutscher Text → englischer) und Mustern; übersetzt beim Laden und per MutationObserver alles, was später gesetzt wird. Standard nach Gerätesprache, Wahl in ⚙️ → Anzeige.
selftest.jsSelbsttest im Browser: ?selbsttest an die Adresse hängen – spielt Abläufe mit simulierter Handy durch.
handy.jsKlasse Handy (API v2 bzw. v3 mit Application ID: HAMP, HSSP mit playback_rate, Uhrabgleich, Upload-Cache), gemeinsame Einstellungen (SHARED_PREFS, beim Laden synchron von /api/prefs), claimHandy(), Helfer $, store, log (auch als Einblendung #toast), pillText (Status-Chips), positionAt.
app.jsModi und Seiten, Menü ☰, Geräte-Fenster (openDevices/closeDevices), Schalter „Geräte folgen dem Video“, Video + Script laden, Sync Video → Handy (doSync), Heatmap, „Läuft gerade“.
library.jsBibliothek (Ordner + Stash), Filter je Modus, Szene öffnen, Stash-Aktionen (★, Favorit, O, Marker), Weiterschauen, Sprites.
control.jsModus Steuern: Direktsteuerung, Pattern-Editor, Script-Baukasten, emergencyStop().
vibe.jsSatisfyer per Web Bluetooth und Intiface, Script → Vibration (targetLevel, 60-ms-Schleife, Gleichmäßigkeit).
fullscreen.jsEigene Player-Leiste, Vollbild, Gesten, Tastatur, STOPP-Knopf, Wake-Lock.
enhance.js · hq.jsBildverbesserung per WebGL · HQ-Stream vom Server (Spulen = Neustart der Umwandlung).
finale.js · tools.jsFinale (lösen / sanfter) · Script-Automatik: Analyse, Presets, pro Szene (processScript), Offset einmessen.
stroker.jsBewegungsgeräte über Intiface (z. B. Handy per Bluetooth).
selfctl.js„Selbst steuern“ für Videos ohne Funscript: Handy per HAMP (Besitzer 'self') bzw. hampLevel() für Bluetooth, Satisfyer über selfVibeLevel().
touchpad.jsTouch-Fläche für 🎮 Steuern und „Selbst steuern“, Umschalter Tempo | 🧪 Position (touchPosSend() → handy.hdspMove(), HDSP über API v2, alle 100 ms der neueste Wert ohne auf die Antwort zu warten – höchstens 2 Befehle unterwegs, Fahrzeit ab 150 ms, bei HTTP 429 langsamer; Bluetooth über moveAll()), ⏺ Aufnahme im gewählten Modus.
test/Automatische Tests mit Node-Bordmitteln: node --test "test/*.test.js" (Script-Logik direkt aus den Dateien, Server mit Testordnern).
settings.jsEinstellungsseite: Bild/HQ-Modus, Anzeige im Player, System, Zurücksetzen.
remote.jsFernbedienung: Bildschirm-Seite (Zustand melden, Befehle ausführen) und Fernbedienungs-Seite.

Kernkonzepte

Zeitachse

mediaTime(), seekTo(), duration() und videoPlaying() sind die einzige Wahrheit über das Video. Sie berücksichtigen den HQ-Stream (startet nach jedem Spulen bei 0 → hq.offset) und die Fernbedienung (Zeit kommt vom Bildschirm). Nie direkt video.currentTime oder video.paused für Gerätelogik verwenden.

Wer steuert die Handy?

handy.owner ist 'video', 'manual', 'self', 'pattern', 'sequence' oder 'finale'. claimHandy(owner) wechselt und sendet das Event handy-owner – die anderen Bereiche setzen sich darauf zurück (z. B. pausiert das Video).

Script-Kette

script.actions → toolActions() (Preset: Analyse + Anpassung in tools.js, pro Szene in scenePresets) → sendActions() (Finale „halbes Tempo“) → Handy (CSV per HSSP) bzw. Satisfyer. Die Heatmap zeigt toolActions().

Vibration

scriptSource() liefert { actions, ms } aus Steuern oder Video. targetLevel() rechnet je nach Übersetzung eine Stärke 0–1, eine 60-ms-Schleife glättet und sendet nur bei Änderung. Not-Aus und Pause setzen sofort 0.

Handy-Sync

doSync() lädt das Script vor (handy.prepare; Upload gemerkt im Browser und auf dem Server über /api/handycache, abgelaufene Adresse → einmal neu hochladen) und startet mit handy.play(mediaTime()·1000 + Offset). Ereignisse am Video lösen resync() aus; alle 30 s wird still nachkorrigiert, alle 5 min die Uhr neu abgeglichen.

Script-Presets

currentPreset() nimmt das Preset der Szene (scenePresets[offsetKey()]) oder den Standard (scriptPreset). analyzeScript() misst Hub (ohne äußerste 2 %), Zitteranteil und Tempo-Streuung; processScript() wendet an: Zittern < 3 % entfernen → Hub strecken bzw. auf 15–85 % bringen → fitHandy(): je nach tools.tooFast erst halveFast() (voller Hub, an zu schnellen Stellen je zwei Bewegungen zu einer) und dann limitSpeed() (zur Mitte hin). Grenze handyMaxSpeed() nach Modell (handy.readInfo(), HANDY_MAX_SPEED) oder von Hand; Sanft ≤ 300, Intensiv +50 %/s. Gezählt wird jeder Punkt einmal, ab 3 % Änderung. presetVibeEven() liefert dem Satisfyer die Gleichmäßigkeit. Ergebnis wird je Script zwischengespeichert.

Oberfläche

Kopfzeile: eine Status-Anzeige #devChip öffnet #devices als Fenster (body.dev-open) – nur dort steht Verbinden (.conn). Im Player steht #devices fest am Video, ohne Reiter. Menü #menu; Sync-Schalter .sync-toggle nur in ⚙️ → Geräte. Beim Stöbern: Mini-Player (body.mini-on) bzw. #nowBar.

Nur verbundene Geräte

updateDevClasses() setzt alle 0,5 s has-handy, has-vibe, has-dev am body, dazu dev-idle (Video ohne Script und „Keine Steuerung“). Das CSS blendet danach Regler, Leisten-Knöpfe, Touch-Haken und „Selbst steuern“ ein oder aus; ändert sich etwas, rechnet die Script-Automatik die Info-Zeile neu. Vom Nutzer ausgeblendete Leisten-Knöpfe: .bar-off (barBtns in settings.js, Reihenfolge per DOM).

Script je Gerät

processScript(actions, 'handy' | 'vibe'): gemeinsame Schritte (Zittern, Hub), danach nur für die Handy limitSpeed(), für den Satisfyer ggf. Glättung. sendActions() geht an die Handy, vibeActions() an den Satisfyer; Offset gilt für beide.

Eigene Scripts & Mitschnitt

saveScriptToLibrary(actions, 'all' | 'section') -> POST /api/userscript: neben das Ordner-Video (wx) oder data/scripts/; im Verzeichnis data/userscripts.json vermerkt – nur diese darf der Player ersetzen/löschen, Originale nie. loadSceneScript() wählt Original oder eigenes (scriptChoice), renderScriptBox() zeigt den Script-Bereich; „Abschnitt“ = mergeSection(). Der Mitschnitt (selfctl.js) tastet alle 100 ms an mediaTime() ab; touchToTempoActions() macht daraus Stöße, deren Tempo dem Wert folgt.

Einstellungen speichern

In localStorage über store.get/set (Präfix hp.); Schlüssel aus SHARED_PREFS zusätzlich auf dem Server (/api/prefs, für alle Geräte). Patterns und Script-Baukasten: /api/store. Erweiterte Einstellungen: Klasse .adv, eingeblendet mit body.adv-on.

HTTP-API

Alle Antworten JSON, außer Datei-Streams. Mit AUTH_USER/AUTH_PASS gilt Basic-Auth für alles außer /healthz und /api/version. Änderungen (alles außer GET/HEAD, außer /heresphere) nur von der eigenen Seite (Sec-Fetch-Site / Origin, sonst 403); dazu X-Frame-Options, frame-ancestors 'self', nosniff, Referrer-Policy: same-origin.

MethodePfadZweck
GET/healthz · /api/versionDocker-Healthcheck · Versionsnummer
GET/api/libraryVideos unter /media mit zugehörigem Funscript
GET/media/<pfad>Datei aus /media mit Range-Support
GET PUT/api/storePatterns und Script-Baukasten ({ patterns, sequences })
GET/api/stash/status · /api/stash/saved-filters · /api/stash/filtersStash erreichbar? · gespeicherte Filter · Tags/Darsteller/Studios
GET/api/stash/scenes?…Szenenliste (Suche, Sortierung, Filter, Seiten, continue=1, only2d=1 = nur mit Stash-Tag „2D“)
GET/api/stash/scene/<id>Szenen-Details inkl. Marker, Bewertung, O, Position
POST/api/stash/scene/<id>/rating · favorite · o · o-undo · marker · activity · playZurückschreiben, z. B. { rating100 }, { favorite }, { seconds, title }, { resume, played }
GET/stash/scene/<id>/stream|funscript|screenshot|preview|webp|vtt/thumbs|vtt/spriteDurchgereichte Stash-Dateien (nur diese)
GET/hq/stream?src=…&t=…&preset=…&h=…HQ-Stream ab Zeitpunkt t (ffmpeg, VAAPI oder CPU)
GET/api/hq/status · probe · benchHQ verfügbar? · Auflösung/Format · Leistungstest
GET POST/heresphere · /heresphere/stash/<id> · /heresphere/media/…HereSphere-API (Bewertung/Favorit im POST). Mit hsVrOnly nur VR – auch für einzelne Szenen: Stash-Szenen mit VR-Tag (isVrTag), Ordner-Videos mit VR-Kennung im Namen (vrFormat). needsMediaSource: false überspringt ffprobe; Liste 60 s, ffprobe-Ergebnisse dauerhaft gemerkt
POST/heresphere/authMit HereSphere-Passwort: { username, password } → { "auth-token", access }; ohne gültiges Token (Kopfzeile auth-token oder ?hs=) antwortet die API mit access: -1. Alle zurückgegebenen Adressen tragen ?hs=
WS/intiface?target=IP:PORTWebSocket-Weiterleitung zu Intiface, nur private IPv4 (mit „nur Heimnetz“ nur 192.168.x / 10.x), nur von der eigenen Seite
GET PUT/api/handycache?k=v2|v3:<sha256>Merkliste schon hochgeladener Handy-Scripts (data/handy-cache.json, max. 3000): GET → { url }, PUT { k, url }
GET PUT/api/prefsGemeinsame Einstellungen aller Geräte (data/prefs.json); PUT führt zusammen, null löscht
GET POST DEL/api/userscript?src=…Eigenes Script: POST { src, funscript } ersetzt ein eigenes bzw. legt es neben das Ordner-Video oder nach data/scripts/ (Original daneben bleibt); GET liefert es; DELETE löscht nur eigene
GET/api/userscriptsVerzeichnis der eigenen Scripts { src: { where, at } }
GET PUT/api/backupSicherung: Einstellungen, Patterns, Server-Einstellungen, eigene Scripts samt Inhalt; PUT spielt sie ein
GET/api/heat?src=stash:<id>|media:<pfad>Intensität eines Scripts: 48 Abschnitte mit mittlerem Tempo + Durchschnitt (für die Kacheln)
GET PUT/api/settingsServer-Einstellungen ({ heresphere, hsVrOnly, hsPassword, intifaceLan }, gespeichert in data/settings.json; vom Passwort nur Salz + Hash)
GET/api/remote/time · /api/remote/eventsServeruhr · SSE-Verbindung der Fernbedienung
POST/api/remote/state · /api/remote/cmdZustand eines Bildschirms · Befehl an einen Bildschirm

Protokoll der Fernbedienung

Jede offene Seite verbindet sich mit GET /api/remote/events?id=…&name=…&screen=1 (Server-Sent Events). Die ID ist pro Tab fest (sessionStorage), damit ein Neuladen die Verbindung nicht verliert. Der Server speichert nichts, er reicht nur weiter.

Ereignisse vom Server

  • screens – { list: [{ id, name, state }], peers }
  • state – { id, state } eines Bildschirms
  • cmd – { from, cmd, arg } an diesen Bildschirm

Befehle (POST /api/remote/cmd)

open (Szene), play, pause, toggle, seek (Sekunden), stop (Not-Aus), fs (Vollbild), refresh (Stash-Infos neu), hello (Zustand jetzt senden).

// Zustand eines Bildschirms (POST /api/remote/state, alle 3 s und bei jeder Änderung)
{
  "src": { "kind": "stash", "id": "123", "title": "…", "interactive": true },
  "t": 760.4,            // Position in Sekunden …
  "at": 1793448541635,   // … gemessen zu dieser Serverzeit (ms)
  "playing": true, "paused": false, "duration": 1865,
  "handy": true,         // Bildschirm steuert die Handy selbst
  "info": { "rating100": 80, "favorite": true, "oCounter": 2 },
  "markers": [{ "seconds": 312, "title": "Lieblingsstelle 5:12" }],
  "error": ""            // "autoplay" = Browser wartet auf eine Berührung
}

Die Fernbedienung rechnet t + (Serverzeit jetzt − at) / 1000. Die Abweichung zur Serveruhr misst jede Seite mit fünf Anfragen an /api/remote/time (die schnellste zählt) und wiederholt das jede Minute. Kommt 8 s kein Zustand, gilt der Bildschirm als angehalten.

Konfiguration (Umgebungsvariablen)

Mit Docker stehen alle Werte in der .env (Vorlage .env.example); die docker-compose.yml liest sie nur aus. Zusätzlich dort: DOMAIN, PLAYER_HOST, Traefik, Medienordner, RENDER_GID, PUID/PGID, TZ.

VariableStandardBedeutung
PORT · HOST8080 · 0.0.0.0Wo der Server lauscht
MEDIA_DIR/mediaVideoordner; jeder Unterordner = eigene Quelle
DATA_DIR./dataDaten des Players (Einstellungen, Patterns, eigene Scripts, Merklisten)
LOG_LEVEL–debug = jede Anfrage in docker logs (sonst nur langsame > 1 s)
STASH_URL · STASH_API_KEY–Stash-Adresse, API-Key nur bei Login in Stash
STASH_INSECURE–1 = selbstsigniertes Zertifikat akzeptieren
STASH_FAVORITE_TAG · STASH_MARKER_TAGFavorit · LieblingsstelleTag für Favoriten bzw. gesetzte Marker
HQ_ACCEL · HQ_DEVICEauto · /dev/dri/renderD128vaapi oder cpu; Grafikgerät
HQ_MAX3Gleichzeitige HQ-Umwandlungen
HERESPHEREanoff = HereSphere fest aus (sonst in der Oberfläche schaltbar)
INTIFACE_LAN_ONLY–1 = Intiface-Weiterleitung fest nur 192.168.x / 10.x (sonst in der Oberfläche schaltbar)
FFMPEG · FFPROBEffmpeg · ffprobePfade zu den Programmen
AUTH_USER · AUTH_PASS–Optionale Basic-Auth (sonst über den Reverse Proxy absichern)
TLS_PFX (+_PASS) / TLS_CERT + TLS_KEY–Direkt HTTPS ohne Reverse Proxy

Arbeiten am Code

Lokal starten

MEDIA_DIR=./testmedia DATA_DIR=./data \
STASH_URL=http://stash:9999 \
node server.js
# → http://localhost:8080

Ohne Stash läuft alles mit den Ordnern. Web Bluetooth geht auf localhost auch ohne HTTPS.

Ablauf einer Version

  1. Ändern, dann node --check für alle JS-Dateien und node --test "test/*.test.js".
  2. Im Browser testen (Testvideo + Funscript, Stash ggf. nachbauen) – dazu ?selbsttest.
  3. VERSION, CHANGELOG.md, bei größeren Versionen RELEASE-NOTES-x.y.z.md – jeweils „was & warum“.
  4. Commit, Tag vX.Y.Z, ZIP für Docker per git archive.

Sprache

Code-Kommentare auf Deutsch (sie erklären das Warum). Oberfläche zweisprachig: deutsche Texte im Code, neue Texte in public/i18n-en.js ergänzen.

Keine Abhängigkeiten

Nur Node-Standardbibliothek und Browser-APIs. Kein Build, kein Bundler.

Sicherheit zuerst

Alles, was Geräte bewegt, muss über emergencyStop() stoppbar sein und bei fehlender Verbindung anhalten.

Feature-Seite pflegen

Neue Funktionen in docs/index.html ergänzen, technische Details hier.

Tests

Ohne zusätzliche Pakete, mit Node-Bordmitteln. Vor jeder Version laufen lassen:

node --test "test/*.test.js"

test/scripts.test.js

Funscript einlesen, Script-Automatik (Analyse, Zittern, Hub, Tempo-Grenze), Touch-Aufnahme als Tempo/Position, Intensitäts-Zusammenfassung, Takt-Schutz mit Vibrations-Ausnahme, halbes Tempo, Modell-Erkennung. Die Funktionen werden direkt aus den Browser-Dateien gelesen (loadFunctions in test/helpers.js).

test/server.test.js

Startet server.js mit Testordnern: Bibliothek, gemeinsame Einstellungen, Intensität, eigene Scripts (eigene ersetzen, Originale nie, löschen), Sicherung, Fernbedienungs-Relais über Server-Sent Events, HereSphere-Schalter, „nur VR“ und Passwort, Herkunftsprüfung und Schutz-Header, Script-Merkliste.