Cast a full browser tab to a Chromecast or Google TV on your local network — video and audio from that tab only. This mirrors what you see in the browser window; it does not use Chrome's built-in "cast this video" / dominant-media detection.
cast "https://example.com/watch"- Opens the URL in a dedicated Chrome window
- Captures tab frames at a steady frame rate
- Taps audio from that Chrome instance only (other Mac apps keep their normal output)
- Encodes video + audio to HLS with ffmpeg
- Tells your Chromecast to play the stream
Buffered mode is on by default (~45s delay on the TV) for smoother, higher-quality playback.
| Requirement | Notes |
|---|---|
| macOS 14.2+ | Required for per-tab audio capture via AudioTee |
| Python 3.10+ | |
| uv | Used by install.sh to install the cast CLI |
| Google Chrome | Used via Playwright (channel="chrome") |
| ffmpeg | With H.264 encoding (h264_videotoolbox on Apple Silicon recommended) |
| Chromecast / Google TV | On the same LAN as your Mac |
| Swift (optional) | Only needed to build AudioTee if not pre-built |
First install uv (if you don't have it):
curl -LsSf https://astral.sh/uv/install.sh | shThen clone and run the installer:
git clone <this-repo>
cd fix-casting
./install.shThis uses uv tool install to install the cast command into ~/.local/bin (in its own isolated environment), downloads Playwright's Chromium (fallback), and builds AudioTee when Swift is available.
Make sure ~/.local/bin is on your PATH:
uv tool update-shell # or: export PATH="$HOME/.local/bin:$PATH"To update later, just re-run ./install.sh. To remove: uv tool uninstall fix-casting.
Install ffmpeg if needed:
brew install ffmpegcast "https://streamfree.app/embed/soccer/ecuador-vs-ivory-coast?quality=1080p&category=soccer"The CLI discovers Chromecast devices on your network and prompts you to pick one. A Chrome window opens locally showing the page; the TV plays the mirrored stream.
Press Ctrl+C to stop.
cast <url> [options]
--width WIDTH Viewport width (default: 1920)
--height HEIGHT Viewport height (default: 1080)
--fps FPS Encode frame rate (default: 30 buffered, 23–24 unbuffered)
--jpeg-quality Q Tab-capture JPEG quality 1–100 (default: 92)
--video-bitrate MBPS Override H.264 target bitrate in Mbps (default: by resolution, 15 at 1080p)
--buffered / --no-buffered
Buffered mode for quality vs latency (default: buffered)
--no-audio Video only, skip tab audio capture
--audio-offset-ms MS Manual A/V trim; positive delays audio (default: 0)
--adblock / --no-adblock
Block ads/trackers in the captured tab (default: on)
--headless Hide the local browser window (may break some players)
--discovery-timeout SEC Seconds to search for devices (default: 5)
--stats Print pipeline timing stats every 10s (diagnose lag)
--stats-interval SEC Seconds between stats reports (default: 10)
--tv-poll-interval SEC Seconds between Chromecast status polls when --stats is set (default: 2)
--tui Live full-screen dashboard of all stats + audio-offset knob
Video is always encoded as H.264 (universally supported on Chromecast) and
captured via CDP Page.startScreencast.
Lower latency (less buffering on the TV):
cast --no-buffered "https://example.com"720p for less CPU usage:
cast --width 1280 --height 720 "https://example.com"Lower capture quality to cut CPU/bandwidth (or raise it for a sharper image):
cast --jpeg-quality 60 "https://example.com"Video only (no audio tap):
cast --no-audio "https://example.com"Smoother 60fps (needs a Chromecast that supports 1080p60):
cast --fps 60 "https://example.com"Dial in lip-sync if audio leads video (positive delays audio):
cast --audio-offset-ms 200 "https://example.com"Live dashboard with a real-time audio-offset knob:
cast --tui "https://example.com"--tui replaces the scrolling --stats text with a full-screen
Textual dashboard. Every metric shows a
number, a sparkline of its recent history, and a one-line description, and turns
yellow → red as it degrades. A status bar at the top gives an at-a-glance
health dot per segment. Metrics are grouped by pipeline segment:
- ① Capture — CDP screencast + AudioTee (incoming): capture FPS, Chrome→app frame lag, decode time, audio pipe backlog, audio warnings.
- ② Encode pipeline (internal): encode FPS, frame age, queue depth, ffmpeg stdin-write time, repeats/resyncs.
- ③ HLS stream (outgoing): segment count, newest-segment age, rotation.
- ④ TV / Chromecast (playback): state, position, advance-vs-wall-clock, micro-stalls, non-playing polls.
- ⑤ A/V sync: cumulative audio-lead drift, frames dropped, ffmpeg restarts.
The audio-offset knob at the bottom adjusts lip-sync live. Use the
-100 / -10 / +10 / +100 ms buttons or the keyboard:
| Key | Action |
|---|---|
[ / ] |
audio offset −10 / +10 ms |
{ / } |
audio offset −100 / +100 ms |
r |
reset offset to 0 |
q |
stop the cast and exit |
Changes apply after presses settle (one quick ffmpeg re-sync, so expect a brief glitch). Note that the buffered HLS delay means an offset change takes ~the buffer length to become visible on the TV — adjust in small steps.
On by default: the captured tab blocks ad/tracker requests so ads don't appear
in what you cast (and don't waste bitrate). It derives an ad/tracker domain
list from uBlock Origin's network filter lists + Peter Lowe's ad-server list
and blocks them natively in Chrome via CDP Network.setBlockedURLs — no
per-request Python work, so it doesn't steal CPU from the encoder (an earlier
per-request interception approach did, causing video stutter). Lists are fetched
once and cached for a day in ~/.cache/fix-casting/adblock. Use --no-adblock
to turn it off.
It's a deliberately focused set (~6.5k domains): the full EasyList/
EasyPrivacy carries ~50k domains, and setBlockedURLs matching cost grows with
that, enough to contend for CPU. This set blocks the major ad/tracker servers
(measured: googlesyndication, doubleclick, analytics, GTM, scorecard, taboola)
while leaving legit CDNs alone. It blocks at the domain level only — no
element-hiding or path rules — so some first-party ads on a given site may slip
through.
The Chromecast pulls HLS segments over your LAN; if the stream's bitrate exceeds
what the network/TV sustains, its buffer drains and playback stalls. To find the
ceiling, sweep --video-bitrate upward with --stats and watch the tv line:
cast --stats --stats-interval 5 --video-bitrate 8 "https://example.com"Read the tv stats line:
position +5s/5s(playback keeping pace with wall-clock) and statePLAYING→ that bitrate is sustainable.stall ~Ns,micro-stalls ~Ns, ornon-playing … (BUFFERING …)→ the network can't keep up at that bitrate; back it off.
Step up (e.g. 6 → 8 → 10 → 12 Mbps) and stay at each setting a few minutes — with
the default ~45s buffer, an over-high bitrate takes that long to drain the buffer
before it stalls. For faster feedback use --no-buffered (small buffer, fails
fast), then re-confirm your chosen bitrate in normal buffered mode. The highest
setting that stays PLAYING with no stalls is your ceiling; back off ~20% for
headroom against network jitter.
URL → Chrome tab → JPEG frames + PCM audio
↓
ffmpeg (HLS)
↓
HTTP server on your LAN
↓
Chromecast plays stream.m3u8
- Video capture uses CDP
Page.startScreencast: Chrome pushes JPEG frames as the page paints (up to ~60fps), and every frame is acknowledged withPage.screencastFrameAckso the stream never stalls. - Even-paced encoding samples the latest frame at a constant cadence on one thread and feeds ffmpeg on another, with a bounded queue between them. Even sampling keeps motion smooth (no judder) even when an ffmpeg write stalls on an HLS segment flush, while the constant rate keeps the TV buffer from draining. ffmpeg is restarted automatically if it dies or stays backpressured.
- Audio capture uses a vendored AudioTee binary to tap only the cast browser's processes. Your other apps are not routed through a virtual audio device.
- Streaming uses ffmpeg to mux H.264 + AAC into an HLS playlist served from
/tmp/cast-tab-stream/. - Casting uses pychromecast to load the HLS URL on the default media receiver.
No Chromecast found
Ensure the TV and Mac are on the same network. Try increasing --discovery-timeout.
No audio on TV
Audio requires AudioTee. Re-run ./install.sh or build manually:
cd vendor/audiotee && swift build -c releaseIf audio still fails, start playback in the local Chrome window (click Play). The tool retries autoplay automatically.
Frozen or choppy video
Try --no-buffered to rule out buffer-related delay, or lower resolution with --width 1280 --height 720.
High CPU
Lower --fps, resolution, --jpeg-quality, or use --no-buffered.
Lag builds up over time
Run with --stats and watch which stage drifts:
cast --stats "https://example.com"Every 10 seconds you'll see something like:
[stats] capture 28.5/30 fps, capture avg 35ms peak 52ms, behind 3x
[stats] encode 30.0/30 fps to ffmpeg, frame age avg 8ms peak 20ms, stdin write avg 0.5ms
[stats] hls 12 segments, newest segment 1.2s old
[stats] tv PLAYING, playback position 142s
How to read it:
- capture fps drops or capture ms rises → Chrome tab capture is the bottleneck (CPU or page complexity)
- behind Nx → capture is missing its schedule and skipping ticks
- encode fps drops but capture is fine → ffmpeg encoding is struggling
- frame age rises → encoder is feeding ffmpeg stale frames (usually means capture slowed down)
- newest segment age rises → ffmpeg/HLS segment generation is falling behind
- tv position creeping further behind real time → TV buffer or network (expected ~45s with
--buffered)
cast_tab/
cli.py Command-line entry point
browser.py Chrome tab capture
streamer.py ffmpeg HLS encoder + HTTP server
caster.py Chromecast playback
audio.py Per-tab audio via AudioTee
devices.py mDNS Chromecast discovery
vendor/audiotee/ Vendored AudioTee (with stereo mixdown patch)
install.sh Setup script
See individual dependencies: pychromecast, Playwright, AudioTee.