Control the browser on your projector from your phone. No alt-tabbing, no extra hardware, no app to install.
One PC, three monitors and a projector. She watches YouTube and Netflix on the projector through a browser while I game on the other screens. Every ad break or change of show meant alt-tabbing out of a game to help her navigate. That got old fast.
A mobile-first web app that runs on the same PC and lets anyone on the local network drive the projector's browser from their phone. Scan a QR code, or open a URL.
Phone (browser) ──WiFi/HTTPS──▸ FastAPI server ──CDP──▸ Brave / Chrome / Edge (on the projector)
Every input is injected through the Chrome DevTools Protocol with Playwright. The active window's focus is never stolen, and nothing touches the OS mouse, keyboard or system volume — so the game on the other monitors carries on undisturbed. That constraint is the whole point of the project, and it's why volume is scoped to the browser's own audio session rather than the Windows mixer.
Two screens and nothing else.
The live mirror of the projector fills the screen. A strip at the top picks how you drive it:
| Mode | What your finger does |
|---|---|
| Touch | Tap the picture to click that exact spot. Drag to scroll — the page moves as you move, not when you let go. |
| Trackpad | Slide anywhere to push a pointer around; tap to click it. Two fingers scroll. |
| Point | Aim the phone at the screen like a wand. Tap the bar to start, tap again to click, Centre to re-anchor straight ahead. |
In Trackpad and Point modes the pointer is drawn on the mirror, so you can always see what you're about to click. Pinch to zoom into the picture up to 5×; two fingers pan once you're zoomed in.
Below the picture, always visible and never inside the gesture area:
- A scrub bar with elapsed and remaining time — drag it to skip an intro or an ad break
- Back · skip back 10s · play/pause · skip forward 10s · type · fullscreen
- A volume slider and mute, scoped to the browser's audio session
Turn the phone sideways and the deck folds into a rail down the right-hand side, which roughly doubles the size of the picture.
Air mice usually accumulate per-frame deltas, and they drift: one dropped sample, one sleep, or one moment of over-rotating past a screen edge and the pointer is permanently out of step with where you are actually aiming. That is what makes them feel unreliable.
This one is absolute. Pressing Centre records the phone's orientation as "straight ahead", and each frame the only question asked is how far from there it is now pointing. Yaw and pitch relative to that origin map onto the screen, so overshooting costs nothing — come back and the pointer comes back with you. The maths runs in quaternions, so there are no gimbal flips near vertical, and it is roll invariant: twisting the phone or turning it to landscape changes nothing. It also notices whether you are holding the phone flat like a remote or upright like a camera, and aims along the top edge or out of the back accordingly. A One Euro filter removes hand tremor — about ±0.35° of shake moves the pointer less than 0.4% of the screen — without adding lag to real movement.
Play/pause, mute and skipping act on the actual <video> element on the page, so they work the same on
YouTube, Netflix, iPrima, Twitch or anything else with a video in it. Only fullscreen falls back to the
site's own keyboard shortcut, because browsers require a real key press for it.
The type button opens a sheet with a normal text box — the phone's own keyboard, with autocomplete and punctuation — and sends what you type to whatever field is focused on the TV.
- One search-or-address box. Type a domain to go there, or anything else to search the web.
- One list of shortcuts. The ones from
config.jsonare built in and can't be deleted by mistake; Add this page saves whatever is currently open, and Edit reveals the delete buttons. - The tabs already open in the browser, tap to switch.
- A gear in the header holds pointer speed, scroll speed, vibration, keep-screen-awake, a flip for Point's up/down axis, reload and reconnect. That's the whole settings screen.
- If the browser isn't running, a full screen offers to start it — you don't have to touch the PC.
- If the phone loses the server, the frozen frame is greyed out and a screen says so, rather than leaving a stale picture that looks live.
The server runs over HTTPS with a self-signed certificate (key.pem + cert.pem). This is required,
not optional:
- Point mode needs
DeviceOrientationEvent, which browsers only expose on a secure origin. Safari won't even prompt for sensor permission over plain HTTP. wss://is required for the mirror and pointer streams once the page itself is served over HTTPS.
On first connect your phone will show a certificate warning — accept it once.
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=browser-remote"If the certificates are present the server also listens on port 80 and redirects to HTTPS, so typing the bare IP into a phone browser works.
A PyQt6 window to configure and run everything.
Setup — browser picker (auto-detects Brave, Chrome, Edge), executable path, profile (the real one, so logins stick, or an isolated one), start URL, CDP port, server port, and which display to open the browser fullscreen on.
Active — a QR code, the connection URL, a live status line from /api/dashboard, and a collapsible
log drawer.
Tray — minimises to the system tray with Show / Start browser / Stop server / Quit.
Settings live in %APPDATA%/ProjectorRemote/config.json. pyinstaller projector_remote.spec builds a
standalone exe.
| Method | Path | Description |
|---|---|---|
GET |
/ |
The phone UI |
GET |
/api/status |
Connection, page title, viewport, video state |
POST |
/api/connect |
Re-attach to the browser over CDP |
POST |
/api/start |
Launch the browser remotely |
GET |
/api/tabs · POST /api/tabs/{i} |
List and switch tabs |
POST |
/api/click · /api/dblclick · /api/hover |
Pointer events at relative (0–1) coordinates |
POST |
/api/scroll |
Scroll at a position |
POST |
/api/cursor/show · hide · move · set · click · scroll |
The on-page pointer |
GET |
/api/cursor/position |
Where the pointer is |
GET |
/api/screenshot.jpg |
Single frame, used when the websocket is down |
POST |
/api/press/{key} |
One key, from a small allow-list |
POST |
/api/type |
Type a string, optionally followed by Enter |
POST |
/api/media/{action} |
play_pause, mute, fullscreen, seek_forward, seek_back, seek_to |
POST |
/api/mute |
Mute the tab — audio session, then video element, then the site's key |
GET/POST |
/api/volume |
The browser's audio session volume |
POST |
/api/navigate · /api/go-back · /api/go-forward · /api/reload |
Navigation |
GET/POST/DELETE |
/api/bookmarks |
The one shortcut list |
GET/POST |
/api/settings |
Pointer speed, scroll speed, cursor hide delay |
GET |
/api/dashboard |
Status for the desktop launcher |
GET |
/api/diagnose |
Why attaching failed, in words, plus the raw facts |
| Path | Description |
|---|---|
/ws/screencast |
Live mirror via CDP Page.startScreencast |
/ws/pointer |
{dx, dy} (trackpad) or {type:"aim", nx, ny} (absolute, 0–1) in, {x, y} back; also {type:"click"} and {type:"scroll"} |
The server talks to the browser over the DevTools port. Three things go wrong, and
GET /api/diagnose says which one it is — the phone shows that message on its standby screen
rather than a generic error.
| What you see | What happened | Fix |
|---|---|---|
| "Nothing is open on the projector yet" | No browser process at all | Tap Start the browser |
| "The browser is already open… without its remote-control port" | A browser was already running when the remote tried to launch one, so the new process just handed off to it and exited. The port never opened. | Close every window of that browser, then Check again. If it recurs, set the launcher's Profile to Separate |
| "Found the port but couldn't talk to it" | The port answers but the DevTools handshake failed | Restart the browser |
Two things that used to make this intermittent, now fixed:
localhostis not127.0.0.1. Chromium binds the DevTools port to IPv4 only, but Windows resolveslocalhostto::1first, so every connect burned about two seconds on a refused IPv6 attempt before falling back — measured here at 22 ms via127.0.0.1against 2051 ms vialocalhost. Everything dials the IPv4 address directly now.- Attaching wasn't serialised.
ensure_connection()is reachable from about thirty endpoints and the phone hits several the instant it opens. They raced, each starting its own Playwright driver. One lock now covers it: twenty simultaneous callers produce exactly one handshake.
The server also probes the port before dialling, so a missing browser fails in about 400 ms with a real explanation instead of waiting out a connect timeout, and it waits up to 25 seconds for a cold profile to come up rather than guessing at a fixed sleep.
| Issue | Details |
|---|---|
| Self-signed certificate | Phones show a warning on first connect. Accept it once. Safari may need you to visit the URL directly and accept before websockets will connect. |
| No authentication | Anyone on the same network who opens the URL can drive the browser. Fine on a home LAN; don't expose the port to the internet. |
| Windows only | Volume control uses pycaw, and browser launching assumes Windows paths. The CDP half is portable; nothing else has been tried. |
| The remote should start the browser | If a browser is already open when the server launches one, the new process hands off to the running instance and exits without ever opening the DevTools port. Close it first, or run the projector browser on a separate profile. |
| Volume needs audio | The slider is greyed out until the browser has an audio session — that is, until something has played. By design: the alternative is turning down the whole PC. |
Requires Windows 10/11, Python 3.10+, Brave / Chrome / Edge, and a phone on the same WiFi.
pip install -r requirements.txt
python -m playwright install chromium
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=browser-remote"
python launcher.pyPick your browser and display, press Start, scan the QR code.
To run it without the GUI:
.\start.ps1or by hand:
& "C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe" `
--remote-debugging-port=9222 `
--user-data-dir="$env:LOCALAPPDATA\BraveSoftware\Brave-Browser\User Data" `
--start-fullscreen https://www.youtube.com
python -m uvicorn server:app --host 0.0.0.0 --port 5000 --ssl-keyfile key.pem --ssl-certfile cert.pemBrowser-Remote/
├── launcher.py # PyQt6 desktop launcher
├── server.py # FastAPI backend (CDP bridge)
├── config.py # Settings, persisted to %APPDATA%
├── projector_remote.spec # PyInstaller build
├── key.pem / cert.pem # Self-signed TLS
├── static/index.html # The phone UI — one file, no build step
├── start.ps1 # One-click launcher
└── requirements.txt
| Setting | Default | Description |
|---|---|---|
brave_path |
Auto-detected | Browser executable |
cdp_port |
9222 |
Chrome DevTools Protocol port |
port |
5000 |
HTTPS server port |
user_data_dir |
The browser's own | Profile directory, so logins persist |
projector_monitor |
0 |
Which display the browser opens fullscreen on |
default_url |
https://www.youtube.com |
Opened when the browser launches |
sites |
YouTube, Netflix, Oneplay | Built-in shortcuts |
cursor_sensitivity |
1.0 |
Pointer speed for Trackpad and Point |
scroll_speed |
1.0 |
Scroll multiplier |
cursor_hide_delay |
2.0 |
Seconds before the idle pointer fades from the page |
Python 3.10+, FastAPI, Playwright (CDP), uvicorn, pycaw for the browser audio session, PyQt6 for the launcher, PyInstaller for the build. The frontend is one hand-written HTML file — no framework, no build step, no dependencies, and no webfont, so it paints the moment it lands on the phone.
MIT