Skip to content

Repository files navigation

Browser Remote

Control the browser on your projector from your phone. No alt-tabbing, no extra hardware, no app to install.

The problem

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.

The solution

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.


The phone

Two screens and nothing else.

Watch

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.

How Point mode works

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.

Sites

  • 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.json are 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.

Everything else

  • 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.

Why HTTPS

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.


Desktop launcher

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.


Server API

REST

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

WebSocket

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"}

When it won't attach

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:

  • localhost is not 127.0.0.1. Chromium binds the DevTools port to IPv4 only, but Windows resolves localhost to ::1 first, so every connect burned about two seconds on a refused IPv6 attempt before falling back — measured here at 22 ms via 127.0.0.1 against 2051 ms via localhost. 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.

Known limits

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.

Setup

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.py

Pick your browser and display, press Start, scan the QR code.

To run it without the GUI:

.\start.ps1

or 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.pem

Project structure

Browser-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

Configuration

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

Tech

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.

License

MIT

About

I have 1 PC connected to 3 monitors and a projector. My girlfriend watches TV (YouTube, Netflix, etc.) on the projector through a browser while I game on the other monitors. Whenever an ad appeared or she wanted to switch shows, I had to alt-tab out of my game to help her navigate. That got old fast. This is the solution.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages