Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions cast-receiver/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Chromecast Custom Receiver Setup

This directory contains the custom Google Cast receiver application used for low-latency WebRTC streaming from JetKVM to Chromecast / Google TV devices.

## Prerequisites

- A Google account (personal Gmail works)
- A Chromecast, Chromecast with Google TV, or Google TV Streamer on the same LAN as the JetKVM
- An HTTPS web server to host the receiver HTML (e.g., Caddy, nginx, GitHub Pages)

## Setup Steps

### 1. Register as a Cast Developer

1. Go to the [Google Cast SDK Developer Console](https://cast.google.com/publish)
2. Pay the one-time $5 registration fee
3. Accept the Terms of Service

### 2. Create a Custom Receiver Application

1. Click **Add New Application**
2. Select **Custom Receiver**
3. Fill in:
- **Name**: `JetKVM` (or any name you prefer)
- **Receiver Application URL**: The HTTPS URL where you'll host `index.html` (e.g., `https://yourdomain.com/cast-receiver/index.html`)
4. Click **Save**
5. Note the **Application ID** (e.g., `F311D863`)

### 3. Register Your Device for Testing

Unpublished apps only work on registered test devices:

1. In the Cast Developer Console, go to **Cast Receiver Devices**
2. Click **Add New Device**
3. Enter the device's **Cast serial number**:
- On Google TV Streamer: **Settings > System > About > Cast serial number** (NOT the Android serial)
- On Chromecast: Check the box or **Google Home app > Device > Settings > Serial number**
4. Wait **15 minutes** for registration to propagate
5. **Hard reboot** the device (unplug power, wait 10 seconds, plug back in)

### 4. Host the Receiver HTML

The `index.html` file must be served over **HTTPS** with a valid TLS certificate. Options:

**Caddy (recommended for self-hosting):**
```
yourdomain.com {
handle_path /cast-receiver/* {
root * /path/to/cast-receiver
file_server
}
}
```

**GitHub Pages:**
1. Create a repository and push `index.html`
2. Enable GitHub Pages in repository settings
3. Use the resulting `https://username.github.io/repo-name/index.html` URL

### 5. Configure the JetKVM

1. Open the JetKVM web interface
2. Go to **Settings > Video**
3. Set the **Cast Receiver App ID** to the Application ID from step 2
4. Click **Apply**

### 6. Cast

1. In the JetKVM web interface, click the **Cast** button in the toolbar
2. Select your Chromecast / Google TV device from the list
3. The receiver loads on the TV and establishes a direct WebRTC connection to the JetKVM
4. Expected latency: ~300-500ms on LAN

## How It Works

1. The JetKVM connects to the Chromecast via the CASTV2 protocol (TLS on port 8009)
2. It launches the custom receiver app by Application ID
3. The Chromecast loads the receiver HTML from your HTTPS server
4. The JetKVM sends its IP address to the receiver via a custom Cast namespace
5. The receiver opens a WebSocket to the JetKVM for WebRTC signaling
6. A peer-to-peer WebRTC connection is established for H.264 video streaming

## Troubleshooting

- **"Timeout waiting for custom receiver"**: The device is not registered as a test device, or the 15-minute propagation hasn't completed. Hard reboot the device after waiting.
- **Black screen on TV, no video**: Check the receiver's console via `chrome://inspect` in Chrome (device must be on the same network). Look for WebSocket connection errors.
- **Cast button shows no devices**: Ensure the Chromecast is on the same LAN/subnet as the JetKVM. mDNS discovery requires multicast to work.
- **Mixed content errors**: The receiver HTML must be served over HTTPS. The WebSocket connection to the JetKVM uses `ws://` which is allowed from Cast receiver contexts.

## Publishing (Optional)

Once testing is complete, you can publish the app in the Cast Developer Console to make it available on all Chromecast devices without per-device registration.
159 changes: 159 additions & 0 deletions cast-receiver/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; }
body { background: #111; color: #0f0; font: 20px monospace; padding: 20px; width: 100vw; height: 100vh; overflow: hidden; }
video { width: 100vw; height: 100vh; position: fixed; top: 0; left: 0; object-fit: contain; z-index: 1; }
#log { position: fixed; top: 0; left: 0; z-index: 10; padding: 20px; background: rgba(0,0,0,0.8); max-height: 100vh; overflow: auto; transition: opacity 1s; }
#log.hidden { opacity: 0; pointer-events: none; }
</style>
</head>
<body>
<div id="log">Cast receiver loading...</div>
<video id="video" autoplay playsinline muted></video>

<script>
const logEl = document.getElementById('log');
const videoEl = document.getElementById('video');
const NAMESPACE = 'urn:x-cast:com.jetkvm.cast';

function log(msg) {
const line = document.createElement('div');
line.textContent = new Date().toISOString().substr(11,8) + ' ' + msg;
logEl.appendChild(line);
logEl.scrollTop = logEl.scrollHeight;
console.log('[JetKVM]', msg);
}

function hideLog() {
setTimeout(() => logEl.classList.add('hidden'), 5000);
}

log('Loading CAF receiver SDK...');
</script>

<script src="https://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js"></script>

<script>
log('CAF SDK loaded, initializing...');

try {
const context = cast.framework.CastReceiverContext.getInstance();
const playerManager = context.getPlayerManager();

// Don't handle default media loads
playerManager.setMessageInterceptor(
cast.framework.messages.MessageType.LOAD,
(request) => { log('Ignoring LOAD request'); return null; }
);

// Listen for our custom namespace
context.addCustomMessageListener(NAMESPACE, (event) => {
log('Got message: ' + JSON.stringify(event.data));
const data = event.data;
if (data.type === 'connect') {
log('Connecting to JetKVM at ' + data.ip);
connectWebRTC(data.ip);
}
});

// Start the receiver
const options = new cast.framework.CastReceiverOptions();
options.disableIdleTimeout = true;
options.customNamespaces = {};
options.customNamespaces[NAMESPACE] = cast.framework.system.MessageType.JSON;

log('Starting receiver context...');
context.start(options);
log('Receiver context started OK');

} catch(e) {
log('ERROR: ' + e.message);
}

// --- WebRTC ---

let pc = null;
let ws = null;

function connectWebRTC(ip) {
if (pc) { pc.close(); pc = null; }
if (ws) { ws.close(); ws = null; }

const wsUrl = 'ws://' + ip + '/webrtc/signaling/cast';
log('Opening WebSocket: ' + wsUrl);

ws = new WebSocket(wsUrl);

ws.onopen = () => log('WebSocket connected');

ws.onerror = (e) => log('WebSocket error');

ws.onclose = (e) => log('WebSocket closed: code=' + e.code);

ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
log('WS recv: ' + msg.type);

if (msg.type === 'device-metadata') {
setupPeerConnection();
} else if (msg.type === 'answer') {
const answer = JSON.parse(atob(msg.data));
pc.setRemoteDescription(new RTCSessionDescription(answer))
.then(() => log('Remote description set'))
.catch(e => log('setRemoteDesc error: ' + e.message));
} else if (msg.type === 'new-ice-candidate' && msg.data) {
pc.addIceCandidate(new RTCIceCandidate(msg.data))
.catch(e => log('addIce error: ' + e.message));
}
};
}

async function setupPeerConnection() {
log('Creating RTCPeerConnection...');

pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});

pc.ontrack = (event) => {
log('Got video track');
videoEl.srcObject = event.streams[0] || new MediaStream([event.track]);
videoEl.play().then(() => { log('Playing!'); hideLog(); })
.catch(e => log('Play error: ' + e.message));
};

pc.oniceconnectionstatechange = () => {
log('ICE: ' + pc.iceConnectionState);
};

pc.onicecandidate = (event) => {
if (event.candidate && ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'new-ice-candidate', data: event.candidate }));
}
};

// Data channels required by JetKVM
pc.createDataChannel('rpc', { ordered: true });
pc.createDataChannel('hidrpc', { ordered: true });
pc.createDataChannel('hidrpc-unreliable-ordered', { ordered: true, maxRetransmits: 0 });
pc.createDataChannel('hidrpc-unreliable-nonordered', { ordered: false, maxRetransmits: 0 });
pc.createDataChannel('terminal', { ordered: true });

pc.addTransceiver('video', { direction: 'recvonly' });

try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const sdpB64 = btoa(JSON.stringify(pc.localDescription));
ws.send(JSON.stringify({ type: 'offer', data: { sd: sdpB64 } }));
log('Offer sent');
} catch(e) {
log('Offer error: ' + e.message);
}
}
</script>
</body>
</html>
Loading