Browser control panel
The plugin serves the Live screen's camera controls at
http://localhost:9980, in a
browser window you can park beside OBS. Nobody has to touch the phone.
Opening it#
Open http://localhost:9980 on the computer running OBS. The panel
is on by default; the switch is Tools → LensLink Settings → Browser
control panel.
Loopback only. The panel binds to 127.0.0.1,
so nothing on the network can reach it — deliberate, since it can start
cameras and has no authentication.
Panel port (default 9980) is a plugin-wide
setting if something else already has that port.
What is on it#
Same controls as the app's Live screen, laid out the same way:
- Status pill with dot, word and latency. A second pill appears while lip-sync calibration runs, and offers Recalibrate once locked.
- Source tabs, when more than one camera source is live — one page controls every phone.
- Lens buttons (
.5 · 1× · 2 · 3, the active one yellow with the live zoom) and a zoom slider under them. Click another lens to switch, the active one to reset its zoom;2is a 48 MP Main camera's full-detail crop. - Flashlight, left of the lenses on a camera with a flash. While it is on, a brightness slider appears under the lenses; click its readout to go back to full brightness.
- Green screen, beside the lenses, when it was on at stream start or has been turned on since.
- The adjust tray: Focus · WB · EV · ISO · Shutter chips and one dial. A yellow A marks what is on auto; moving the dial takes that setting manual, and clicking the selected chip again hands it back to auto (on EV, back to 0).
- Under the dial: Calibrate on WB (click it, then click the white paper in the still of the shot that appears), Natural motion blur on Shutter, then Lock, which freezes what auto is doing right now (Shift-click locks or unlocks everything), Flashlight and Flip. See Live camera controls.
- Format changes mid-stream: resolution, frame rate, codec, microphone, and Stabilization (off while depth assist runs).
- Subject distance, while the green screen runs with depth assist: drag the slider for 0.5–5.0 m, or past 5 m for All, and click the readout to go back to Auto.
- Start camera while idle (a reminder to arm the phone instead, when remote start isn't armed), red Stop camera while live, each with an Auto-start toggle.
- Pause holds the stream without ending it: the phone stays connected with its camera running, OBS keeps the source and shows a dimmed, blurred still with a pause symbol, and Resume picks it straight back up. Audio keeps flowing, so a phone doubling as the microphone doesn't cut out.
It mirrors changes made on the phone, pausing while you drag so it never fights your hand. A LensLink Screen source has no camera controls, and the panel says so rather than showing dead sliders.
The HTTP API#
The panel is one client of a small HTTP API on the same port; the
Stream Deck plugin or a script can be another. Every endpoint except /api/sources and
/api/diagnostics takes an optional ?src=<id>,
with an id from /api/sources. Without it the plugin
uses the first registered source, the one /api/sources lists
first, which can be a LensLink Screen source. With more than one source,
pass src.
| Response | When |
|---|---|
404 {"error":"unknown source"} | src names no source: it was removed, or OBS restarted. The request does nothing, rather than reading or controlling a different phone. |
400 {"error":"bad src"} | src is empty, not a whole number, or given twice. |
503 no sources | There are no LensLink sources at all. |
Ids count up as sources are created and start over when OBS restarts, so
read them from /api/sources rather than storing one. The panel
itself re-picks a source when its id stops working.
Reading state#
| Request | Returns |
|---|---|
GET /api/sources | Every source: id, name, connected, standby, armed (in standby and accepting start_stream), screen. |
GET /api/status | status text (in OBS's language), its tone (idle, wait, ready, live or error, for coloring it without reading the text), plus screen, standby, armed, connected, autoStart and the lip-sync sync stage. |
GET /api/diagnostics | The whole diagnostics report as plain text — versions, hardware, settings and per-source state. Same content as the button in Tools → LensLink Settings. |
GET /api/state | The phone's last camera-state snapshot (keys below), or {} before the phone has sent one. The same cache the source properties read, which is how the three surfaces stay in step. |
State keys#
The snapshot is the app's own JSON, passed through as is. Keys an older app
doesn't know are simply absent, and a few flags (greenScreen,
greenScreenDepth, micEnabled, hdr)
appear only when true.
| Keys | Meaning |
|---|---|
paused, pauseReason | Whether video is held, and who held it: "user" (an operator pause) or "camera" (iOS interrupted capture). |
zoom, maxZoom | Current zoom factor and its upper limit. |
exposureBias | AE bias in EV. |
exposureMode, isoMode, shutterMode, iso, minISO, maxISO, shutterSeconds, minShutterSeconds, maxShutterSeconds, supportsManualExposure | "auto" or "manual" overall (manual when either is), then for ISO and shutter each, the current ISO and shutter, and the ranges the camera accepts. The longest shutter is capped at one frame, so maxShutterSeconds follows the frame rate. |
focusMode, lensPosition, faceFocus, supportsFaceFocus | "auto" or "locked", the focus position (0 to 1), and face-driven focus. |
whiteBalanceMode, whiteBalanceTemperature, whiteBalanceTint, supportsWhiteBalanceLock | "auto" or "locked", the locked temperature in kelvin, and the tint a calibration measured (0 otherwise). |
naturalBlur | Whether auto exposure keeps the shutter at half the frame interval or faster, as set by natural_blur. Absent from apps that predate it. |
flashlight, flashlightLevel, hasFlashlight | Flashlight state, its brightness (0.01 to 1), and whether this camera has one. |
camera, lens, lenses, lensFactors, cropZoom | "front" or "back", the current lens's label, and the labels of every lens on the phone, such as "Main (Wide)" or "Ultra Wide (0.5×)". Labels stay English; they are the values selectLens takes. lensFactors gives each lens's magnification relative to Main, in the same order; cropZoom, when present, is the zoom of Main's full-detail sensor crop. |
stabilization | "off", "standard" or "cinematic", as set by stabilization. |
resolution, fps, codec, resolutions, frameRates, codecs | The current format, and the choices set_format accepts for the current lens. |
quality | "balanced" or "maximum", as set by set_quality. |
hdr, color | "hdr": true for HLG, "color": "log" for Apple Log; both absent for standard color. |
supportsGreenScreen, greenScreen, greenScreenDepth, greenScreenMaxDistance | Green screen support, whether it is on, whether depth assist is running, and the subject-distance cutoff in meters (-1 while it is on Auto). |
micEnabled, mic, mics | Present only while the phone mic is the source's audio: the selected mic's id, and every choice as [{"id": "...", "name": "..."}]. Send an id back with the mic command; name is for display. |
Sending commands#
POST /api/control takes one JSON command per request (up to 512
bytes) and passes it to the phone as is. Unknown commands are ignored by
design, so a newer panel and an older app degrade rather than break.
POST /api/control
{ "cmd": "zoom", "value": 2.0 }
{ "cmd": "exposure_bias", "value": -0.5 }
{ "cmd": "exposure", "mode": "manual", "iso": 400, "shutterSeconds": 0.004 }
{ "cmd": "exposure", "mode": "auto" }
{ "cmd": "exposure", "shutterMode": "manual", "shutterSeconds": 0.01 }
{ "cmd": "focus", "mode": "locked", "lensPosition": 0.42 }
{ "cmd": "focus", "mode": "auto" }
{ "cmd": "focus", "faces": true }
{ "cmd": "white_balance", "mode": "locked", "temperature": 5600 }
{ "cmd": "white_balance", "mode": "auto" }
{ "cmd": "white_balance", "mode": "calibrate" }
{ "cmd": "white_balance", "mode": "calibrate", "x": 0.31, "y": 0.62 }
{ "cmd": "lock", "target": "whiteBalance", "on": true }
{ "cmd": "stabilization", "mode": "cinematic" }
{ "cmd": "natural_blur", "on": true }
{ "cmd": "flashlight", "on": true, "level": 0.6 }
{ "cmd": "flip" }
{ "cmd": "selectLens", "label": "Ultra Wide (0.5×)" }
{ "cmd": "start_stream" }
{ "cmd": "stop_stream" }
{ "cmd": "pause_stream" }
{ "cmd": "resume_stream" }
{ "cmd": "set_format", "resolution": "1080p", "fps": 60, "codec": "hevc" }
{ "cmd": "set_quality", "quality": "maximum" }
{ "cmd": "mic", "id": "builtin:2" }
{ "cmd": "green_screen", "on": true, "maxDistance": 2.5 }
white_balancewith"mode": "calibrate"locks white balance to white paper or a gray card atx,y(0–1 from the top-left of the picture, as OBS shows it), or the middle of the picture without them; the state then reports"locked"with the measured temperature and tint.lockfreezes what auto is doing right now fortarget:"focus","whiteBalance","exposure"(ISO and shutter) or"all"."on": falsehands it back to auto.green_screen'smaxDistanceis in meters:0for no cutoff (All),-1for Auto, otherwise 0.5–5.0. Either field can be sent alone.selectLensswitches to the lens whoselabelmatches one in the state'slenses. The phone ignores a label it doesn't have, or a lens that can't do the current resolution and frame rate.pause_streamandresume_streamare the panel's Pause and Resume. They need no arming, since they can only hold a stream that is already running; the state'spausedfollows them.set_qualitytakes"balanced"or"maximum", the app's Quality setting, and applies to a live stream straight away.mictakes anidfrom the state'smics; a stale id is ignored.- Camera commands apply only while the phone is streaming; sent while it is idle, they are dropped.
| Request | Effect |
|---|---|
POST /api/autostart | {"on":true|false} — the same property the auto-start checkbox edits. An empty body is rejected, not read as false. |
POST /api/recalibrate | No body. Starts a fresh lip-sync measurement. |
POST /api/still, then GET /api/still | A still of the next frame as a small BMP, for picking a calibration point. The POST asks for it; the GET returns 204 until it is ready, then the image once. |
start_stream works only while the phone has remote start armed; check armed in /api/status first. stop_stream always works.
Examples#
# Start the camera on the only connected phone (it must be armed)
curl -X POST http://localhost:9980/api/control -d '{"cmd":"start_stream"}'
# Zoom the source with id 2 to 3x
curl -X POST 'http://localhost:9980/api/control?src=2' -d '{"cmd":"zoom","value":3.0}'
# What is connected right now?
curl -s http://localhost:9980/api/sources
Turning it off#
Clear Browser control panel in Tools → LensLink Settings. The port closes immediately; everything else is unaffected.