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.
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).
| Datei | Aufgabe |
|---|---|
server.js | Anfragen verteilen: Anmeldung, Schutz-Header, Herkunftsprüfung, Routen, Intiface-WebSocket-Weiterleitung, Start-Übersicht und langsame Anfragen in docker logs. |
lib/config.js · lib/util.js | Umgebungsvariablen und feste Werte · sichere Pfade (safeJoin), readBody, sendFile mit Range, cleanTitle. |
lib/library.js | Ordner-Bibliothek, gemerkt; neu eingelesen, wenn sich die Ordner ändern (librarySignature) bzw. im Hintergrund nach 30 s. |
lib/settings.js · lib/data.js | Server-Einstellungen (HereSphere, Passwort-Hash, Intiface) · gemeinsame Einstellungen, Intensität, eigene Scripts (data/userscripts.json), Handy-Merkliste, Sicherung, Patterns. |
lib/stash.js · lib/heresphere.js | Stash-GraphQL, Filter, Zurückschreiben, Proxy · HereSphere-API (nur VR, Passwort, Liste gemerkt). |
lib/hq.js · lib/remote.js | HQ-Stream über ffmpeg (ffprobe-Ergebnisse gemerkt) · Fernbedienungs-Relais (Server-Sent Events). |
i18n.js · i18n-en.js | Sprache: 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.js | Selbsttest im Browser: ?selbsttest an die Adresse hängen – spielt Abläufe mit simulierter Handy durch. |
handy.js | Klasse 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.js | Modi 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.js | Bibliothek (Ordner + Stash), Filter je Modus, Szene öffnen, Stash-Aktionen (★, Favorit, O, Marker), Weiterschauen, Sprites. |
control.js | Modus Steuern: Direktsteuerung, Pattern-Editor, Script-Baukasten, emergencyStop(). |
vibe.js | Satisfyer per Web Bluetooth und Intiface, Script → Vibration (targetLevel, 60-ms-Schleife, Gleichmäßigkeit). |
fullscreen.js | Eigene Player-Leiste, Vollbild, Gesten, Tastatur, STOPP-Knopf, Wake-Lock. |
enhance.js · hq.js | Bildverbesserung per WebGL · HQ-Stream vom Server (Spulen = Neustart der Umwandlung). |
finale.js · tools.js | Finale (lösen / sanfter) · Script-Automatik: Analyse, Presets, pro Szene (processScript), Offset einmessen. |
stroker.js | Bewegungsgerä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.js | Touch-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.js | Einstellungsseite: Bild/HQ-Modus, Anzeige im Player, System, Zurücksetzen. |
remote.js | Fernbedienung: 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.
| Methode | Pfad | Zweck |
|---|---|---|
| GET | /healthz · /api/version | Docker-Healthcheck · Versionsnummer |
| GET | /api/library | Videos unter /media mit zugehörigem Funscript |
| GET | /media/<pfad> | Datei aus /media mit Range-Support |
| GET PUT | /api/store | Patterns und Script-Baukasten ({ patterns, sequences }) |
| GET | /api/stash/status · /api/stash/saved-filters · /api/stash/filters | Stash 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 · play | Zurückschreiben, z. B. { rating100 }, { favorite }, { seconds, title }, { resume, played } |
| GET | /stash/scene/<id>/stream|funscript|screenshot|preview|webp|vtt/thumbs|vtt/sprite | Durchgereichte 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 · bench | HQ 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/auth | Mit 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:PORT | WebSocket-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/prefs | Gemeinsame 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/userscripts | Verzeichnis der eigenen Scripts { src: { where, at } } |
| GET PUT | /api/backup | Sicherung: 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/settings | Server-Einstellungen ({ heresphere, hsVrOnly, hsPassword, intifaceLan }, gespeichert in data/settings.json; vom Passwort nur Salz + Hash) |
| GET | /api/remote/time · /api/remote/events | Serveruhr · SSE-Verbindung der Fernbedienung |
| POST | /api/remote/state · /api/remote/cmd | Zustand 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 Bildschirmscmd–{ 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.
| Variable | Standard | Bedeutung |
|---|---|---|
PORT · HOST | 8080 · 0.0.0.0 | Wo der Server lauscht |
MEDIA_DIR | /media | Videoordner; jeder Unterordner = eigene Quelle |
DATA_DIR | ./data | Daten 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_TAG | Favorit · Lieblingsstelle | Tag für Favoriten bzw. gesetzte Marker |
HQ_ACCEL · HQ_DEVICE | auto · /dev/dri/renderD128 | vaapi oder cpu; Grafikgerät |
HQ_MAX | 3 | Gleichzeitige HQ-Umwandlungen |
HERESPHERE | an | off = 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 · FFPROBE | ffmpeg · ffprobe | Pfade 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
- Ändern, dann
node --checkfür alle JS-Dateien undnode --test "test/*.test.js". - Im Browser testen (Testvideo + Funscript, Stash ggf. nachbauen) – dazu
?selbsttest. VERSION,CHANGELOG.md, bei größeren VersionenRELEASE-NOTES-x.y.z.md– jeweils „was & warum“.- Commit, Tag
vX.Y.Z, ZIP für Docker pergit 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.