| <html> | |
| <head> | |
| <style> | |
| body { | |
| font-family: Verdana, Arial; | |
| font-size: 12px; | |
| } | |
| /* Give the same font styling to form elements. */ | |
| input, select, textarea, button { | |
| font-family: inherit; | |
| font-size: inherit; | |
| } | |
| .content { | |
| display: flex; | |
| flex-direction: column; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .description { | |
| padding-bottom: 5px; | |
| } | |
| .description .title { | |
| font-size: 120%; | |
| font-weight: bold; | |
| } | |
| .route_controls { | |
| flex: 0; | |
| align-self: center; | |
| text-align: right; | |
| padding-bottom: 5px; | |
| } | |
| .route_controls .label { | |
| display: inline-block; | |
| vertical-align: top; | |
| font-weight: bold; | |
| } | |
| .route_controls .control { | |
| width: 500px; | |
| } | |
| .messages { | |
| flex: 1; | |
| min-height: 100px; | |
| border: 1px solid gray; | |
| overflow: auto; | |
| } | |
| .messages .message { | |
| padding: 3px; | |
| border-bottom: 1px solid #cccbca; | |
| } | |
| .messages .message .timestamp { | |
| font-size: 90%; | |
| font-style: italic; | |
| } | |
| .messages .status { | |
| background-color: #d6d6d6; /* light gray */ | |
| } | |
| .messages .sent { | |
| background-color: #c5e8fc; /* light blue */ | |
| } | |
| .messages .recv { | |
| background-color: #fcf4e3; /* light yellow */ | |
| } | |
| .message_controls { | |
| flex: 0; | |
| text-align: right; | |
| padding-top: 5px; | |
| } | |
| .message_controls textarea { | |
| width: 100%; | |
| height: 10em; | |
| } | |
| </style> | |
| <script language="JavaScript"> | |
| // Application state. | |
| var demoMode = false; | |
| var currentSubscriptionId = null; | |
| var currentRouteId = null; | |
| // List of currently supported source protocols. | |
| var allowedSourceProtocols = ['cast', 'dial']; | |
| // Values from cef_media_route_connection_state_t. | |
| var CEF_MRCS_UNKNOWN = 0; | |
| var CEF_MRCS_CONNECTING = 1; | |
| var CEF_MRCS_CONNECTED = 2; | |
| var CEF_MRCS_CLOSED = 3; | |
| var CEF_MRCS_TERMINATED = 4; | |
| function getStateLabel(state) { | |
| switch (state) { | |
| case CEF_MRCS_CONNECTING: return "CONNECTING"; | |
| case CEF_MRCS_CONNECTED: return "CONNECTED"; | |
| case CEF_MRCS_CLOSED: return "CLOSED"; | |
| case CEF_MRCS_TERMINATED: return "TERMINATED"; | |
| default: break; | |
| } | |
| return "UNKNOWN"; | |
| } | |
| // Values from cef_media_sink_icon_type_t. | |
| var CEF_MSIT_CAST = 0; | |
| var CEF_MSIT_CAST_AUDIO_GROUP = 1; | |
| var CEF_MSIT_CAST_AUDIO = 2; | |
| var CEF_MSIT_MEETING = 3; | |
| var CEF_MSIT_HANGOUT = 4; | |
| var CEF_MSIT_EDUCATION = 5; | |
| var CEF_MSIT_WIRED_DISPLAY = 6; | |
| var CEF_MSIT_GENERIC = 7; | |
| function getIconTypeLabel(type) { | |
| switch (type) { | |
| case CEF_MSIT_CAST: return "CAST"; | |
| case CEF_MSIT_CAST_AUDIO_GROUP: return "CAST_AUDIO_GROUP"; | |
| case CEF_MSIT_CAST_AUDIO: return "CAST_AUDIO"; | |
| case CEF_MSIT_MEETING: return "MEETING"; | |
| case CEF_MSIT_HANGOUT: return "HANGOUT"; | |
| case CEF_MSIT_EDUCATION: return "EDUCATION"; | |
| case CEF_MSIT_WIRED_DISPLAY: return "WIRED_DISPLAY"; | |
| case CEF_MSIT_GENERIC: return "GENERIC"; | |
| default: break; | |
| } | |
| return "UNKNOWN"; | |
| } | |
| /// | |
| // Manage show/hide of default text for form elements. | |
| /// | |
| // Default messages that are shown until the user focuses on the input field. | |
| var defaultSourceText = 'Enter URN here and click "Create Route"'; | |
| var defaultMessageText = 'Enter message contents here and click "Send Message"'; | |
| function getDefaultText(control) { | |
| if (control === 'source') | |
| return defaultSourceText; | |
| if (control === 'message') | |
| return defaultMessageText; | |
| return null; | |
| } | |
| function hideDefaultText(control) { | |
| var element = document.getElementById(control); | |
| var defaultText = getDefaultText(control); | |
| if (element.value === defaultText) | |
| element.value = ''; | |
| } | |
| function showDefaultText(control) { | |
| var element = document.getElementById(control); | |
| var defaultText = getDefaultText(control); | |
| if (element.value === '') | |
| element.value = defaultText; | |
| } | |
| function initDefaultText() { | |
| showDefaultText('source'); | |
| showDefaultText('message'); | |
| } | |
| /// | |
| // Retrieve current form values. Return null if validation fails. | |
| /// | |
| function getCurrentSource() { | |
| var sourceInput = document.getElementById('source'); | |
| var value = sourceInput.value; | |
| if (value === defaultSourceText || value.length === 0 || value.indexOf(':') < 0) { | |
| return null; | |
| } | |
| // Validate the URN value. | |
| try { | |
| var url = new URL(value); | |
| if ((url.hostname.length === 0 && url.pathname.length === 0) || | |
| !allowedSourceProtocols.includes(url.protocol.slice(0, -1))) { | |
| return null; | |
| } | |
| } catch (e) { | |
| return null; | |
| } | |
| return value; | |
| } | |
| function getCurrentSink() { | |
| var sinksSelect = document.getElementById('sink'); | |
| if (sinksSelect.options.length === 0) | |
| return null; | |
| return sinksSelect.value; | |
| } | |
| function getCurrentMessage() { | |
| var messageInput = document.getElementById('message'); | |
| if (messageInput.value === defaultMessageText || messageInput.value.length === 0) | |
| return null; | |
| return messageInput.value; | |
| } | |
| /// | |
| // Set disabled state of form elements. | |
| /// | |
| function updateControls() { | |
| document.getElementById('source').disabled = hasRoute(); | |
| document.getElementById('sink').disabled = hasRoute(); | |
| document.getElementById('create_route').disabled = | |
| hasRoute() || getCurrentSource() === null || getCurrentSink() === null; | |
| document.getElementById('terminate_route').disabled = !hasRoute(); | |
| document.getElementById('message').disabled = !hasRoute(); | |
| document.getElementById('send_message').disabled = !hasRoute() || getCurrentMessage() === null; | |
| } | |
| /// | |
| // Manage the media sinks list. | |
| /// | |
| /* | |
| Expected format for |sinks| is: | |
| [ | |
| { | |
| name: string, | |
| type: string ('cast' or 'dial'), | |
| id: string, | |
| icon: int | |
| }, ... | |
| ] | |
| */ | |
| function updateSinks(sinks) { | |
| var sinksSelect = document.getElementById('sink'); | |
| // Currently selected value. | |
| var selectedValue = sinksSelect.options.length === 0 ? null : sinksSelect.value; | |
| // Build a list of old (existing) values. | |
| var oldValues = []; | |
| for (var i = 0; i < sinksSelect.options.length; ++i) { | |
| oldValues.push(sinksSelect.options[i].value); | |
| } | |
| // Build a list of new (possibly new or existing) values. | |
| var newValues = []; | |
| for(var i = 0; i < sinks.length; i++) { | |
| newValues.push(sinks[i].id); | |
| } | |
| // Remove old values that no longer exist. | |
| for (var i = sinksSelect.options.length - 1; i >= 0; --i) { | |
| if (!newValues.includes(sinksSelect.options[i].value)) { | |
| sinksSelect.remove(i); | |
| } | |
| } | |
| // Add new values that don't already exist. | |
| for(var i = 0; i < sinks.length; i++) { | |
| var sink = sinks[i]; | |
| if (oldValues.includes(sink.id)) | |
| continue; | |
| var opt = document.createElement('option'); | |
| opt.innerHTML = sink.name + ' (' + sink.model_name + ', ' + sink.type + ', ' + | |
| getIconTypeLabel(sink.icon) + ', ' + sink.ip_address + ':' + sink.port + ')'; | |
| opt.value = sink.id; | |
| sinksSelect.appendChild(opt); | |
| } | |
| if (sinksSelect.options.length === 0) { | |
| selectedValue = null; | |
| } else if (!newValues.includes(selectedValue)) { | |
| // The previously selected value no longer exists. | |
| // Select the first value in the new list. | |
| selectedValue = sinksSelect.options[0].value; | |
| sinksSelect.value = selectedValue; | |
| } | |
| updateControls(); | |
| return selectedValue; | |
| } | |
| /// | |
| // Manage the current media route. | |
| /// | |
| function hasRoute() { | |
| return currentRouteId !== null; | |
| } | |
| function createRoute() { | |
| console.assert(!hasRoute()); | |
| var source = getCurrentSource(); | |
| console.assert(source !== null); | |
| var sink = getCurrentSink(); | |
| console.assert(sink !== null); | |
| if (demoMode) { | |
| onRouteCreated('demo-route-id'); | |
| return; | |
| } | |
| sendCefQuery( | |
| {name: 'createRoute', source_urn: source, sink_id: sink}, | |
| (message) => onRouteCreated(JSON.parse(message).route_id) | |
| ); | |
| } | |
| function onRouteCreated(route_id) { | |
| currentRouteId = route_id; | |
| showStatusMessage('Route ' + route_id + '\ncreated'); | |
| updateControls(); | |
| } | |
| function terminateRoute() { | |
| console.assert(hasRoute()); | |
| var source = getCurrentSource(); | |
| console.assert(source !== null); | |
| var sink = getCurrentSink(); | |
| console.assert(sink !== null); | |
| if (demoMode) { | |
| onRouteTerminated(); | |
| return; | |
| } | |
| sendCefQuery( | |
| {name: 'terminateRoute', route_id: currentRouteId}, | |
| (unused) => {} | |
| ); | |
| } | |
| function onRouteTerminated() { | |
| showStatusMessage('Route ' + currentRouteId + '\nterminated'); | |
| currentRouteId = null; | |
| updateControls(); | |
| } | |
| /// | |
| // Manage messages. | |
| /// | |
| function sendMessage() { | |
| console.assert(hasRoute()); | |
| var message = getCurrentMessage(); | |
| console.assert(message !== null); | |
| if (demoMode) { | |
| showSentMessage(message); | |
| setTimeout(function(){ if (hasRoute()) { recvMessage('Demo ACK for: ' + message); } }, 1000); | |
| return; | |
| } | |
| sendCefQuery( | |
| {name: 'sendMessage', route_id: currentRouteId, message: message}, | |
| (unused) => showSentMessage(message) | |
| ); | |
| } | |
| function recvMessage(message) { | |
| console.assert(hasRoute()); | |
| console.assert(message !== undefined && message !== null && message.length > 0); | |
| showRecvMessage(message); | |
| } | |
| function showStatusMessage(message) { | |
| showMessage('status', message); | |
| } | |
| function showSentMessage(message) { | |
| showMessage('sent', message); | |
| } | |
| function showRecvMessage(message) { | |
| showMessage('recv', message); | |
| } | |
| function showMessage(type, message) { | |
| if (!['status', 'sent', 'recv'].includes(type)) { | |
| console.warn('Invalid message type: ' + type); | |
| return; | |
| } | |
| if (message[0] === '{') { | |
| try { | |
| // Pretty print JSON strings. | |
| message = JSON.stringify(JSON.parse(message), null, 2); | |
| } catch(e) {} | |
| } | |
| var messagesDiv = document.getElementById('messages'); | |
| var newDiv = document.createElement("div"); | |
| newDiv.innerHTML = | |
| '<span class="timestamp">' + (new Date().toLocaleString()) + | |
| ' (' + type.toUpperCase() + ')</span><br/>'; | |
| // Escape any HTML tags or entities in |message|. | |
| var pre = document.createElement('pre'); | |
| pre.appendChild(document.createTextNode(message)); | |
| newDiv.appendChild(pre); | |
| newDiv.className = 'message ' + type; | |
| messagesDiv.appendChild(newDiv); | |
| // Always scroll to bottom. | |
| messagesDiv.scrollTop = messagesDiv.scrollHeight; | |
| } | |
| /// | |
| // Manage communication with native code in media_router_test.cc. | |
| /// | |
| function onCefError(code, message) { | |
| showStatusMessage('ERROR: ' + message + ' (' + code + ')'); | |
| } | |
| function sendCefQuery(payload, onSuccess, onFailure=onCefError, persistent=false) { | |
| // Results in a call to the OnQuery method in media_router_test.cc | |
| return window.cefQuery({ | |
| request: JSON.stringify(payload), | |
| onSuccess: onSuccess, | |
| onFailure: onFailure, | |
| persistent: persistent | |
| }); | |
| } | |
| /* | |
| Expected format for |message| is: | |
| { | |
| name: string, | |
| payload: dictionary | |
| } | |
| */ | |
| function onCefSubscriptionMessage(message) { | |
| if (message.name === 'onSinks') { | |
| // List of sinks. | |
| updateSinks(message.payload.sinks_list); | |
| } else if (message.name === 'onRouteStateChanged') { | |
| // Route status changed. | |
| if (message.payload.route_id === currentRouteId) { | |
| var connection_state = message.payload.connection_state; | |
| showStatusMessage('Route ' + currentRouteId + | |
| '\nconnection state ' + getStateLabel(connection_state) + | |
| ' (' + connection_state + ')'); | |
| if ([CEF_MRCS_CLOSED, CEF_MRCS_TERMINATED].includes(connection_state)) { | |
| onRouteTerminated(); | |
| } | |
| } | |
| } else if (message.name === 'onRouteMessageReceived') { | |
| // Route message received. | |
| if (message.payload.route_id === currentRouteId) { | |
| recvMessage(message.payload.message); | |
| } | |
| } | |
| } | |
| // Subscribe to ongoing message notifications from the native code. | |
| function startCefSubscription() { | |
| currentSubscriptionId = sendCefQuery( | |
| {name: 'subscribe'}, | |
| (message) => onCefSubscriptionMessage(JSON.parse(message)), | |
| (code, message) => { | |
| onCefError(code, message); | |
| currentSubscriptionId = null; | |
| }, | |
| true | |
| ); | |
| } | |
| function stopCefSubscription() { | |
| if (currentSubscriptionId !== null) { | |
| // Results in a call to the OnQueryCanceled method in media_router_test.cc | |
| window.cefQueryCancel(currentSubscriptionId); | |
| } | |
| } | |
| /// | |
| // Example app load/unload. | |
| /// | |
| function initDemoMode() { | |
| demoMode = true; | |
| var sinks = [ | |
| { | |
| name: 'Sink 1', | |
| type: 'cast', | |
| id: 'sink1', | |
| icon: CEF_MSIT_CAST | |
| }, | |
| { | |
| name: 'Sink 2', | |
| type: 'dial', | |
| id: 'sink2', | |
| icon: CEF_MSIT_GENERIC | |
| } | |
| ]; | |
| updateSinks(sinks); | |
| showStatusMessage('Running in Demo mode.'); | |
| showSentMessage('Demo sent message.'); | |
| showRecvMessage('Demo recv message.'); | |
| } | |
| function onLoad() { | |
| initDefaultText(); | |
| if (window.cefQuery === undefined) { | |
| // Initialize demo mode when running outside of CEF. | |
| // This supports development and testing of the HTML/JS behavior outside | |
| // of a cefclient build. | |
| initDemoMode(); | |
| return; | |
| } | |
| startCefSubscription() | |
| } | |
| function onUnload() { | |
| if (demoMode) | |
| return; | |
| if (hasRoute()) | |
| terminateRoute(); | |
| stopCefSubscription(); | |
| } | |
| </script> | |
| <title>Media Router Example</title> | |
| </head> | |
| <body bgcolor="white" onLoad="onLoad()" onUnload="onUnload()"> | |
| <div class="content"> | |
| <div class="description"> | |
| <span class="title">Media Router Example</span> | |
| <p> | |
| <b>Overview:</b> | |
| Chromium supports communication with devices on the local network via the | |
| <a href="https://blog.oakbits.com/google-cast-protocol-overview.html" target="_blank">Cast</a> and | |
| <a href="http://www.dial-multiscreen.org/" target="_blank">DIAL</a> protocols. | |
| CEF exposes this functionality via the CefMediaRouter interface which is demonstrated by this test. | |
| Test code is implemented in resources/media_router.html and browser/media_router_test.cc. | |
| </p> | |
| <p> | |
| <b>Usage:</b> | |
| Devices available on your local network will be discovered automatically and populated in the "Sink" list. | |
| Enter a URN for "Source", select an available device from the "Sink" list, and click the "Create Route" button. | |
| Cast URNs take the form "cast:<i><appId></i>?clientId=<i><clientId></i>" and DIAL URNs take the form "dial:<i><appId></i>", | |
| where <i><appId></i> is the <a href="https://developers.google.com/cast/docs/registration" target="_blank">registered application ID</a> | |
| and <i><clientId></i> is an arbitrary numeric identifier. | |
| Status information and messages will be displayed in the center of the screen. | |
| After creating a route you can send messages to the receiver app using the textarea at the bottom of the screen. | |
| Messages are usually in JSON format with a example of Cast communication to be found | |
| <a href="https://github.com/chromiumembedded/cef/issues/2900#issuecomment-1465022620" target="_blank">here</a>. | |
| </p> | |
| </div> | |
| <div class="route_controls"> | |
| <span class="label">Source:</span> | |
| <input type="text" id="source" class="control" onInput="updateControls()" onFocus="hideDefaultText('source')" onBlur="showDefaultText('source')"/> | |
| <br/> | |
| <span class="label">Sink:</span> | |
| <select id="sink" size="3" class="control"></select> | |
| <br/> | |
| <input type="button" id="create_route" onclick="createRoute()" value="Create Route" disabled/> | |
| <input type="button" id="terminate_route" onclick="terminateRoute()" value="Terminate Route" disabled/> | |
| </div> | |
| <div id="messages" class="messages"> | |
| </div> | |
| <div class="message_controls"> | |
| <textarea id="message" onInput="updateControls()" onFocus="hideDefaultText('message')" onBlur="showDefaultText('message')" disabled></textarea> | |
| <br/><input type="button" id="send_message" onclick="sendMessage()" value="Send Message" disabled/> | |
| </div> | |
| </div> | |
| </body> | |
| </html> |