This guide describes the integration of FUG script storage and autonomous playback into a browser page. The integration uses standard browser APIs and requires no framework-specific library.
Open the live browser example in a new tab ยท Download the standalone HTML file
The live example provides a complete working interface for local file upload, import from URL, script management, playback controls, and live playback status.
Related documentation:
The completed browser integration follows this lifecycle:
script_id is selected for playback.FUG executes playback asynchronously. Every playback control returns 202 Accepted before the player-worker applies the action. The SSE stream carries the observable result.
| Requirement | Description |
|---|---|
| FUG base URL | The production API URL. |
| DCK | A valid Device Connection Key for the target device. |
| Device connection | The target device must be connected to the room identified by the DCK for physical movement and forwarded device status. |
| Browser | A modern browser with fetch, ReadableStream, TextDecoderStream, and AbortController support. |
| CORS | The page origin must be allowed by the FUG deployment. The Authorization request header triggers a CORS preflight for cross-origin requests. |
| Script | Funscript JSON or Autoblow time_ms,position CSV content. |
| Base URL | Intended use |
|---|---|
https://fug-prd.feelme.com |
Production traffic. |
Every public scripts and playback request uses the following header:
Authorization: DCK <device_connection_key>
The DCK acts as both the authenticated tenant identity and the target device identity. Browser code must not hardcode a real DCK, include a DCK in a URL, write a DCK to logs, or persist a DCK in localStorage. The live example keeps the value only in a password input and request memory. A production application should supply the value through an existing authenticated application flow.
The scripts and playback feature depends on the FUG store-backed runtime. A deployment with the feature disabled returns 404 Not Found for these routes.
A shared request helper should normalize the base URL, attach DCK authentication, serialize JSON bodies, and surface the API error detail. Centralized request handling prevents inconsistent headers across controls.
async function fugRequest(baseUrl, dck, path, { method = "GET", body } = {}) {
const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, {
method,
headers: {
Authorization: `DCK ${dck}`,
...(body === undefined ? {} : { "Content-Type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(`${response.status}: ${JSON.stringify(error.detail)}`);
}
return response.status === 204 ? null : response.json();
}
The live example adds content-type detection, typed status errors, safe rendering with textContent, and a shared event log.
The status stream should be connected before playback starts. This order captures the earliest playback result and supports correlation between a control acknowledgement and a later status frame.
Native EventSource cannot attach the required Authorization header. An authenticated streaming fetch request is therefore required for GET /api/playback/status.
const response = await fetch(`${baseUrl}/api/playback/status`, {
headers: { Authorization: `DCK ${dck}` },
signal: abortController.signal,
});
if (!response.ok) {
throw new Error(`Status stream returned ${response.status}`);
}
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
The stream sends blocks separated by a blank line. Each JSON frame appears on one or more data: lines. Keepalive comment frames contain no data: field and should be ignored.
Two application frame types are available:
playback contains playback state, media position, point index, speed, offset, and request origin.device_status contains a raw status payload forwarded from the connected device.A reconnect opens a fresh stream. When playback state exists, the new stream receives an initial frame built from the latest checkpoint and desired state. The live example applies capped exponential backoff and stops automatic retries after an authentication failure.
The browser File API exposes selected content without a separate upload server. POST /api/scripts accepts the file text inside a JSON body.
const file = fileInput.files[0];
const metadata = await fugRequest(baseUrl, dck, "/api/scripts", {
method: "POST",
body: {
content: await file.text(),
content_type: file.type || null,
name: file.name,
tags: ["browser-upload"],
},
});
const scriptId = metadata.script_id;
POST /api/scripts/from-url accepts a public script URL. HTTPS is required by default. FUG performs DNS and address checks, validates every redirect, and enforces a streamed byte limit.
const metadata = await fugRequest(baseUrl, dck, "/api/scripts/from-url", {
method: "POST",
body: { url: "https://cdn.example.com/example.funscript" },
});
GET /api/scripts returns owned scripts in newest-first order. The returned metadata can populate a safe <select> element through DOM properties:
const scripts = await fugRequest(baseUrl, dck, "/api/scripts");
for (const script of scripts) {
const option = document.createElement("option");
option.value = script.script_id;
option.textContent = `${script.name || "unnamed"} | ${script.duration_ms} ms`;
scriptSelect.append(option);
}
const accepted = await fugRequest(baseUrl, dck, "/api/playback/play", {
method: "POST",
body: {
script_id: scriptId,
start_ms: 0,
speed_scale: 1.0,
profile: "keon_wifi",
},
});
Possible acceptance statuses are started, resumed, switching, and rejected. A rejection for the same active script still uses HTTP 202 and includes explanatory detail.
| Action | Request |
|---|---|
| Pause | POST /api/playback/pause with no body |
| Seek | POST /api/playback/seek with { "position_ms": 12000 } |
| Sync | POST /api/playback/sync with { "position_ms": 12000 } |
| Speed | POST /api/playback/speed with { "speed": 1.5 } |
| Offset | POST /api/playback/offset with { "offset_ms": -250 } |
| Stop | POST /api/playback/stop with no body |
seek represents explicit interface intent and updates the durable desired position. sync represents drift correction from an external media clock. Sync correction applies only during active playback, only when drift exceeds the configured threshold, and no more frequently than the configured sync interval.
The following response confirms asynchronous acceptance:
{
"status": "accepted",
"request_id": "f9e45c14631c4f90939c16aebd0d65bd",
"detail": null
}
The response does not confirm physical device execution. A matching playback frame provides the observable state:
{
"type": "playback",
"device_key": "<device_connection_key>",
"script_id": "4df8eead6bad4eecb50b236bf49d1c09",
"state": "paused",
"position_ms": 18420,
"index": 126,
"point_count": 820,
"speed_scale": 1.0,
"offset_ms": 0,
"origin": "f9e45c14631c4f90939c16aebd0d65bd",
"detail": null
}
The request_id from the acknowledgement normally appears as origin when the worker applies the direct control event. An origin value of system identifies a system-generated or reconciled event, so correlation should also compare the requested state and values.
The DCK that creates a script owns the script. Listing and deletion are owner-scoped. Metadata lookup and playback are available by script_id to another valid DCK, while source_url remains visible only to the owner. A script ID is therefore shareable application data rather than an authentication credential.
Scripts expire automatically after the configured TTL, currently 48 hours by default. Playback refreshes the stored script lifetime. Explicit deletion remains useful for temporary uploads and quota management.
await fugRequest(baseUrl, dck, `/api/scripts/${encodeURIComponent(scriptId)}`, {
method: "DELETE",
});
A script has no update operation. Replacement requires deletion followed by another upload. POST /api/playback/stop should precede deletion when an active session uses the selected script.
The status request should be cancelled during page cleanup:
abortController.abort();
| Status | Typical cause | Page behavior |
|---|---|---|
400 |
Invalid script content, unsupported format, or blocked URL. | Display the API detail; correct the source before another upload. |
401 |
Missing, malformed, or invalid DCK. | Stop automatic authentication retries and request a valid application credential. |
403 |
A delete request targets a script owned by another DCK. | Keep the script read-only. |
404 |
Script missing or expired; scripts/playback feature disabled. | Refresh the list and verify the selected environment. |
409 |
Per-owner script quota reached. | Delete an unused owned script before another upload. |
413 |
Inline content or fetched content exceeds the configured size cap. | Reject the selected source and show the size limit. |
422 |
Request validation or script limits failed. | Display validation details beside the relevant input. |
429 |
Per-instance SSE stream capacity reached. | Reconnect after a delay. |
502 |
Remote script host returned an error or failed during fetch. | Verify the public source URL and host availability. |
504 |
Remote script fetch timed out. | Retry later or use inline upload. |
A network timeout after play creates an ambiguous result because the request may already have reached FUG. A blind immediate retry can cause an unwanted resume, switch, or rejection event. The status stream should be checked before another play request.
202 Accepted responses.request_id and SSE origin are retained for control correlation.textContent, not injected as HTML.