How a web page can stream multi-GB local files to a Chromecast without uploading them

How a web page can stream multi-GB local files to a Chromecast without uploading them

# chromecast# javascript# webdev# showdev
How a web page can stream multi-GB local files to a Chromecast without uploading themOlly

Presentation API on desktop, the Cast Web Sender SDK on Android, framed binary packets with backpressure, Mediabunny remuxing to fragmented MP4 and MSE on the TV.

The normal Google Cast model is simple: the sender hands the TV a URL, and the TV fetches it. That's perfect for Netflix and YouTube, and useless for a 9 GB MKV sitting in your Downloads folder, because a web page can't run an HTTP server for the TV to fetch from.

The usual workarounds are a desktop app that spins up a local server, a media server (Plex, Jellyfin), or uploading the file to the cloud first. I wanted none of those for Cast to Your TV, a web app where you drop a file in Chrome and it plays on your Chromecast/Google TV. Nothing installed, nothing uploaded.

This post is how that works. Everything below is from the client code that ships to the browser, so you can open DevTools on the site and check it.

The core trick: flip from "pull" to "push"

If the TV can't pull bytes from a URL, the sender has to push them over a message channel and the receiver has to turn them back into a playable stream. On the receiver side, that's what Media Source Extensions (MSE) are for: you create a MediaSource, attach it to a <video>, and appendBuffer() chunks of fragmented MP4 as they arrive.

So the pipeline is:

File (on disk)
  → read in small slices (Blob.slice)
  → demux + remux to fragmented MP4 in the browser (no re-encode when possible)
  → split into framed binary packets
  → message channel to the TV
  → receiver page reassembles → MSE SourceBuffer → <video>
Enter fullscreen mode Exit fullscreen mode

There are two different message channels, because desktop Chrome and Android Chrome expose different casting APIs.

Route 1: desktop Chrome and the Presentation API

Desktop Chrome supports the W3C Presentation API. You construct a PresentationRequest with a URL, call start(), Chrome shows its familiar Cast picker, and you get back a PresentationConnection that supports send() of strings and ArrayBuffers.

The receiver URL here is a dedicated page (/screen, with a random UUID in the hash so each session is unique). It contains little more than a <video>, an <img> and a script. A comment in the shipping code sums up the model: the presentation is rendered by desktop Chrome and mirrored to the TV through Chrome's own Cast receiver. The important part for us is that the sender gets a binary-capable, bidirectional channel to a page we control.

Media goes over that channel as framed packets. Each packet is a 24-byte header followed by up to 256 KiB of payload:

export const MAX_PACKET_BYTES = 256 * 1024;
export const PACKET_MAGIC = 0x43545456; // "CTTV"

export function encodePresentationPacket(bytes, generation, sequence, channel = 0) {
  const result = new ArrayBuffer(24 + bytes.byteLength), header = new DataView(result);
  [PACKET_MAGIC, generation, sequence, channel, bytes.byteLength, PRESENTATION_VERSION]
    .forEach((value, i) => header.setUint32(i * 4, value));
  new Uint8Array(result, 24).set(new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength));
  return result;
}
Enter fullscreen mode Exit fullscreen mode

The header fields earn their keep:

  • generation identifies the current stream, so when a new one starts (a new file, a seek that needs fresh data, different settings), so late packets from the previous stream are discarded instead of corrupting the new one.
  • sequence is a per-stream counter, used for acknowledgements.
  • channel lets subtitles and media share one connection.

Flow control is a small sliding window (4 packets in flight). The receiver replies ACK when a packet is appended, or WAIT when its buffer is full, which resets the sender's timeout without counting as a failure. A packet with no reply in 15 seconds is resent, up to two retries, before the sender gives up with "The TV stopped responding." JSON control messages (HELLO/READY, START, STATUS, COMMAND, NEED_SEEK, SUBTITLE_*, STOPPED) carry a protocol version so mismatched sender/receiver builds fail loudly rather than weirdly.

WAIT turned out to matter more than I expected: a TV's MSE buffer is much smaller than a laptop's, and without explicit backpressure you either overflow it or stall on timeouts.

Route 2: Android Chrome and the Cast Web Sender SDK

Chrome on Android doesn't give you the Presentation API path for Cast, but it does support the Cast Web Sender SDK (cast_sender.js?loadCastFramework=1). That launches a custom Web Receiver app on the TV, and you talk to it with session.sendMessage(namespace, message) on a custom namespace (urn:x-cast:com.casttoyourtv.local).

The catch: Cast custom messages are strings, capped at about 64 KiB. So on Android the same 24-byte-header packets are:

  • limited to 32 KiB of payload,
  • base64-encoded,
  • wrapped as {"v":1,"type":"FRAME","data":"..."},
  • and checked against a 60 KiB ceiling to leave room for SDK framing.
export const ANDROID_PACKET_BYTES = 32 * 1024;
export const MAX_CAST_MESSAGE_BYTES = 60 * 1024;
// ArrayBuffer → base64 → JSON FRAME message
message = JSON.stringify({ v: 1, type: 'FRAME', data: btoa(binary) });
Enter fullscreen mode Exit fullscreen mode

The nice part is that the Android side wraps the Cast session in a small adapter that looks like a PresentationConnection (send(), message events, close), so the exact same transport, windowing and retry logic runs on both platforms. During the handshake the sender also checks that the receiver advertises the expected protocol, a large enough packet size and capabilities.mse === true, and refuses with a clear error otherwise.

Base64 costs about 33% overhead and you're sending many more, smaller messages, so Android has less headroom than desktop. Sustained throughput ultimately depends on the phone, the TV and your Wi-Fi.

Reading multi-GB files without loading them

File is a Blob, and Blob.slice(start, end).arrayBuffer() gives you random access to any byte range without reading the whole file. That's the foundation for big films.

For demuxing and remuxing I use Mediabunny with a BlobSource input. It reads MKV/MP4/etc. containers by seeking around the file, and writes a fragmented MP4 (fastStart: 'fragmented') to an AppendOnlyStreamTarget, which hands out chunks as they're produced. Those chunks go straight into the packetiser above. The first output after the moov box is the MSE init segment; everything after is media fragments.

The decision about whether to touch the actual video is conservative:

  • If the video is H.264/AVC, within the chosen quality, and the receiver's MediaSource.isTypeSupported('video/mp4; codecs="…"') says yes, the video track is copied (remuxed), not re-encoded.
  • Audio is copied if it's AAC-LC stereo (mp4a.40.2, ≤ 2 channels); otherwise it's converted.

Remuxing is cheap compared with transcoding, and because the file is read progressively as it plays, a multi-GB MKV never has to be loaded into memory.

When a file does need real conversion (unusual codecs, image-based subtitles to burn in), it falls back to ffmpeg.wasm (0.12.x) producing fragmented MP4 (-movflags frag_keyframe+empty_moov+default_base_moof). That path needs the input mounted in WASM, so it's capped at 256 MiB. I'd rather be upfront about that limit than pretend a phone can transcode a 4K film in a browser tab.

Subtitles: a tiny MKV/MP4 subtitle reader

Mediabunny's public input API exposes audio and video tracks, not subtitles, so there's a separate small reader for embedded text subtitles in Matroska and MP4. It walks the container structure with a 64 KiB read cache over Blob.slice(), pulls out only the subtitle blocks (never copying media payloads), and has hard limits on time, operations and total text so a malformed file can't hang the tab. Matroska's subtitle mapping spec was the reference.

Everything ends up as WebVTT, because that's what Cast receivers render. SRT conversion is mostly timestamp normalisation (00:01:02,500 → 00:01:02.500); ASS/SSA events are parsed from the [Events] section using the Format: line, with override tags like {\i1} stripped and \N turned into line breaks. Side-loaded .srt/.vtt files go through the same converter, with a 5 MiB text cap.

On the Cast SDK side, subtitle tracks are ordinary chrome.cast.media.Track objects of type TEXT/SUBTITLES with text/vtt content, styled with a TextTrackStyle (white text, transparent background, drop shadow, adjustable fontScale) and switched live with EditTracksInfoRequest, so changing subtitles doesn't restart the film.

Photos and PDFs

Non-video media is simpler, and it's all rendered on the sender:

  • PDFs are rendered page by page with pdf.js to a canvas, then sent as PNGs (with a small cache of rendered pages).
  • HEIC photos are decoded in a Web Worker with a timeout.
  • SVGs are rasterised to a still image; scripts and external resources are deliberately not supported.

Pixel counts and document sizes are bounded so a 200-page PDF or a giant panorama can't exhaust the tab's memory.

The unglamorous bits that made it usable

  • Wake lock. The phone is the server, so if it sleeps, playback dies. The page requests navigator.wakeLock.request('screen') while casting, re-requests it on visibilitychange, and tells the user to keep Chrome in front.
  • Connection diagnostics. Most "it doesn't work" reports are networks blocking discovery (guest Wi-Fi isolation, VPNs, Chrome's local-network permission). There's a built-in checks panel and a discovery comparison page that compares Google's default receiver with mine, so users can tell a network problem from an app problem.
  • Mid-stream state changes. Starting a new stream (a seek the receiver can't serve from its buffer, a different file, new subtitle settings) gets a new generation, and anything tagged with an old one is dropped. That one integer removed a whole class of race conditions.

What I'd tell someone building something similar

  1. MSE plus your own framing beats trying to fake an HTTP server. Once you control both ends of a message channel, local files are just bytes.
  2. Design for backpressure from day one. TVs have small buffers; an explicit WAIT message is simpler than tuning timeouts.
  3. Remux, don't transcode, whenever you can. Codec-copy into fragmented MP4 is what makes multi-GB files practical in a browser.
  4. Write the abstraction once. Making the Android Cast session look like a PresentationConnection meant one transport to test instead of two.

If you want to see it working, drop a video into casttoyourtv.com with a Chromecast on the same Wi-Fi. There's a free tier with ads, plus paid ad-free plans. Happy to answer questions about any of the above in the comments.