Initial commit

This commit is contained in:
hensm
2018-06-08 04:56:02 +01:00
commit d815fb7af0
70 changed files with 8370 additions and 0 deletions

19
LICENSE Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2018 Matt Hensman <m@matt.tf>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
# Caster
Very WIP! Not ready for release. Expect many bugs. Please don't sign builds on AMO with current ID.
## Supported platforms
* Linux
* macOS (TODO)
* Windows (TODO)
Only tested on Linux. mDNS library issue to be fixed. `mdns` only works on Windows, `mdns-js` only works on Linux.
## Building
### Requirements
* NodeJS
* `node` binary in path
* ~~Bonjour SDK (Windows)~~
````
git clone https://github.com/hensm/caster.git
npm install ./ext --prefix ./ext
npm install ./app --prefix ./app
npm run build --prefix ./ext
````
Installer scripts aren't written yet, so registering the native messaging manifest with Firefox and specifiying the path must be done manually:
[MDN: Native Manifests](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Native_manifests)
`path` key within `app/caster_bridge.json` must be set to absolute path of `app/src/launcher.sh` or `app/src/launcher.bat`. Then, the manifest must be either moved to the correct location or the path added to the registry (Windows):
[MDN: Native Manifests # Manifest location](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Native_manifests#Manifest_location)
Extension can be loaded from `about:debugging` as a temporary extension.

7
app/caster_bridge.json Executable file
View File

@@ -0,0 +1,7 @@
{
"name": "caster_bridge"
, "description": ""
, "path": ""
, "type": "stdio"
, "allowed_extensions": [ "caster@matt.tf" ]
}

3
app/install.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/sh
# TODO

114
app/package-lock.json generated Normal file
View File

@@ -0,0 +1,114 @@
{
"requires": true,
"lockfileVersion": 1,
"dependencies": {
"ascli": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/ascli/-/ascli-0.3.0.tgz",
"integrity": "sha1-XmYjDlIZ/j6JUqTvtPIPrllqgTo=",
"requires": {
"colour": "^0.7.1",
"optjs": "^3.2.2"
}
},
"bufferview": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/bufferview/-/bufferview-1.0.1.tgz",
"integrity": "sha1-ev10pF+Tf6QiodM4wIu/3HbNcl0="
},
"bytebuffer": {
"version": "3.5.5",
"resolved": "https://registry.npmjs.org/bytebuffer/-/bytebuffer-3.5.5.tgz",
"integrity": "sha1-em+vGhNRSwg/H8+VQcTJv75+f9M=",
"requires": {
"bufferview": "~1",
"long": "~2 >=2.2.3"
}
},
"castv2": {
"version": "0.1.9",
"resolved": "https://registry.npmjs.org/castv2/-/castv2-0.1.9.tgz",
"integrity": "sha1-0LD6sf0GsNnMpjaIZxbsEpOlkFo=",
"requires": {
"debug": "^2.2.0",
"protobufjs": "^3.2.2"
}
},
"colour": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/colour/-/colour-0.7.1.tgz",
"integrity": "sha1-nLFpkX7F0SwHNtPoaFdG3xyt93g="
},
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"requires": {
"ms": "2.0.0"
}
},
"dns-js": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/dns-js/-/dns-js-0.2.1.tgz",
"integrity": "sha1-XWZimzwOal6w4U8K5wHQX26kZnM=",
"requires": {
"debug": "^2.1.0",
"qap": "^3.1.2"
}
},
"long": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/long/-/long-2.4.0.tgz",
"integrity": "sha1-n6GAux2VAM3CnEFWdmoZleH0Uk8="
},
"mdns-js": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mdns-js/-/mdns-js-1.0.1.tgz",
"integrity": "sha512-dwEtMzmoZCQcGlr004J4m2+W6dCMpCoGQ5kYIEY+7rMPdMM7ztT+1qD9ExmottvLGgbqAVsjllhwU8PyusecPg==",
"requires": {
"debug": "^3.1.0",
"dns-js": "~0.2.1",
"semver": "^5.4.1"
},
"dependencies": {
"debug": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"requires": {
"ms": "2.0.0"
}
}
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
},
"optjs": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/optjs/-/optjs-3.2.2.tgz",
"integrity": "sha1-aabOicRCpEQDFBrS+bNwvVu29O4="
},
"protobufjs": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-3.8.2.tgz",
"integrity": "sha1-vIJuNMOvRpfo0K96Zp5NYSrtzRc=",
"requires": {
"ascli": "~0.3",
"bytebuffer": "~3 >=3.5"
}
},
"qap": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/qap/-/qap-3.3.1.tgz",
"integrity": "sha1-Efno+oiQ/ny5khDA9E0GE7c3LKw="
},
"semver": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz",
"integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA=="
}
}
}

6
app/package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"dependencies": {
"castv2": "^0.1.9",
"mdns-js": "^1.0.1"
}
}

2
app/src/launcher.bat Executable file
View File

@@ -0,0 +1,2 @@
@echo off
node main.js

4
app/src/launcher.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
AVAHI_COMPAT_NOWARN=1 node main.js

463
app/src/main.js Executable file
View File

@@ -0,0 +1,463 @@
"use strict";
const { Client } = require("castv2");
const mdns = require("mdns-js");
const transforms = require("./transforms");
const browser = mdns.createBrowser(mdns.tcp("googlecast"));
// Increase listener limit
require('events').EventEmitter.defaultMaxListeners = 50;
// stdin -> stdout
process.stdin
.pipe(transforms.decode)
.pipe(transforms.response(handleMessage))
.pipe(transforms.encode)
.pipe(process.stdout);
/**
* Encode and send a message to the extension.
*/
function sendMessage (message) {
try {
transforms.encode.write(message);
} catch (err) {}
}
/**
* Handle incoming messages from the extension and forward them to the
* appropriate handlers.
*/
async function handleMessage (message) {
if (message.subject.startsWith("bridge:bridgemedia/")) {
Media.messageHandler(message);
return;
}
if (message.subject.startsWith("bridge:bridgesession/")) {
Session.messageHandler(message);
return;
}
switch (message.subject) {
case "bridge:discover":
browser.discover();
break;
}
}
browser.on("update", service => {
if (!service.txt) return;
const txt = service.txt
.reduce((prev, current) => {
const [ key, value ] = current.split("=");
prev[key] = value;
return prev;
}, {});
sendMessage({
subject: "shim:serviceUp"
, data: {
address: service.addresses[0]
, port: service.port
, id: txt.id
, friendlyName: txt.fn
}
})
});
/*
browser.on("serviceUp", service => {
transforms.encode.write({
subject: "shim:serviceUp"
, data: {
address: service.addresses[0]
, port: service.port
, id: service.txtRecord.id
, friendlyName: service.txtRecord.fn
}
});
});
browser.on("serviceDown", service => {
transforms.encode.write({
subject:"shim:serviceDown"
, data: {
address: service.addresses[0]
, port: service.port
, id: service.txtRecord.id
, friendlyName: service.txtRecord.fn
}
});
})*/
const sessionMap = new Map();
class Session {
static messageHandler (message) {
const { _id } = message;
let session;
if (sessionMap.has(_id)) {
session = sessionMap.get(_id);
}
switch (message.subject) {
case "bridge:bridgesession/initialize":
sessionMap.set(_id, new Session(
message.data.address
, message.data.port
, message.data.appId
, message.data.sessionId));
break;
case "bridge:bridgesession/close":
session.close();
break;
case "bridge:bridgesession/impl_addMessageListener":
session._impl_addMessageListener(message.data.namespace);
break;
case "bridge:bridgesession/impl_sendMessage":
session._impl_sendMessage(
message.data.namespace
, message.data.message
, message.data.messageId)
break;
case "bridge:bridgesession/impl_setReceiverMuted":
session._impl_setReceiverMuted(
message.data.muted
, message.data.volumeId);
break;
case "bridge:bridgesession/impl_setReceiverVolumeLevel":
session._impl_setReceiverVolumeLevel(
message.data.newLevel
, message.data.volumeId);
break;
case "bridge:bridgesession/impl_stop":
session._impl_stop(message.data.stopId);
break;
}
}
constructor (host, port, appId, sessionId) {
this.sessionId = sessionId;
this.clientConnection;
this.clientHeartbeat;
this.clientReceiver;
this.channelMap = new Map();
this.client = new Client();
this.client.connect({ host, port }, () => {
let transportHeartbeat;
this.clientConnection = this.client.createChannel(
"sender-0"
, "receiver-0"
, "urn:x-cast:com.google.cast.tp.connection"
, "JSON");
this.clientHeartbeat = this.client.createChannel(
"sender-0"
, "receiver-0"
, "urn:x-cast:com.google.cast.tp.heartbeat"
, "JSON");
this.clientReceiver = this.client.createChannel(
"sender-0"
, "receiver-0"
, "urn:x-cast:com.google.cast.receiver"
, "JSON");
this.clientConnection.send({ type: "CONNECT" });
this.clientHeartbeat.send({ type: "PING" });
this.clientHeartbeatInterval = setInterval(() => {
if (transportHeartbeat) {
transportHeartbeat.send({ type: "PING" });
}
this.clientHeartbeat.send({ type: "PING" });
}, 5000);
this.clientReceiver.send({
type: "LAUNCH"
, appId
, requestId: 1
});
let sessionCreated = false;
this.clientReceiver.on("message", (data, broadcast) => {
switch (data.type) {
case "RECEIVER_STATUS":
this.sendMessage("shim:session/updateStatus", data.status);
if (!data.status.applications) return;
const receiverApp = data.status.applications[0];
const receiverAppId = receiverApp.appId;
this.app = receiverApp;
if (receiverAppId !== appId) {
// Close session
this.sendMessage("shim:session/stopped");
this.client.close();
clearInterval(this.clientHeartbeatInterval);
return;
}
if (!sessionCreated) {
sessionCreated = true;
this.transport = this.app.transportId;
this.transportId = this.app.transportId;
this.clientId = `client-${Math.floor(Math.random() * 10e5)}`;
this.transportConnect = this.client.createChannel(
this.clientId
, this.transport
, "urn:x-cast:com.google.cast.tp.connection"
, "JSON");
this.transportConnect.send({ type: "CONNECT" });
transportHeartbeat = this.client.createChannel(
this.clientId
, this.transport
, "urn:x-cast:com.google.cast.tp.heartbeat"
, "JSON");
this.sessionId = this.app.sessionId;
this.sendMessage("shim:session/connected", {
sessionId: this.app.sessionId
, namespaces: this.app.namespaces
, displayName: this.app.displayName
, statusText: this.app.displayName
});
}
break;
}
});
});
}
sendMessage (subject, data = {}) {
sendMessage({
subject
, data
, _id: this._id
});
}
createChannel (namespace) {
if (!this.channelMap.has(namespace)) {
this.channelMap.set(namespace
, this.client.createChannel(
this.clientId, this.transport, namespace, "JSON"));
}
}
close () {
this.clientConnection.send({ type: "CLOSE" });
if (this.transportConnect) {
this.transportConnect.send({ type: "CLOSE" });
}
}
_impl_addMessageListener (namespace) {
this.createChannel(namespace);
this.channelMap.get(namespace).on("message", data => {
this.sendMessage("shim:session/impl_addMessageListener", {
namespace: namespace
, data: JSON.stringify(data)
});
})
}
_impl_sendMessage (namespace, message, messageId) {
let error = false;
try {
this.createChannel(namespace);
this.channelMap.get(namespace).send(message);
} catch (err) {
error = true;
}
this.sendMessage("shim:session/impl_sendMessage", {
messageId
, error
});
}
_impl_setReceiverMuted (muted, volumeId) {
let error = false;
try {
this.clientReceiver.send({
type: "SET_VOLUME"
, volume: { muted }
, requestId: 0
});
} catch (err) {
error = true;
}
this.sendMessage("shim:session/impl_setReceiverMuted", {
volumeId
, error
});
}
_impl_setReceiverVolumeLevel (newLevel, volumeId) {
let error = false;
try {
this.clientReceiver.send({
type: "SET_VOLUME"
, volume: { level: newLevel }
, requestId: 0
})
} catch (err) {
error = true;
}
this.sendMessage("shim:session/impl_setReceiverVolumeLevel", {
volumeId
, error
});
}
_impl_stop (stopId) {
let error = false;
try {
this.clientReceiver.send({
type: "STOP"
, sessionId: this.sessionId
, requestId: 0
});
} catch (err) {
error = true;
}
this.client.close();
clearInterval(this.clientHeartbeatInterval);
this.sendMessage("shim:session/impl_stop", {
stopId
, error
});
}
}
const mediaMap = new Map();
class Media {
static messageHandler (message) {
const { _id } = message;
let media;
if (mediaMap.has(_id)) {
media = mediaMap.get(_id);
}
switch (message.subject) {
case "bridge:bridgemedia/initialize":
mediaMap.set(_id
, new Media(
message.data.sessionId
, message.data.mediaSessionId
, _id
, message.data._internalSessionId));
break;
case "bridge:bridgemedia/sendMediaMessage":
const { messageId } = message.data;
let error = false;
try {
media.channel.send(message.data.message);
} catch (err) {
error = true;
}
media.sendMessage("shim:media/sendMediaMessageResponse", {
messageId
, error
});
break;
default:
return;
}
}
constructor (sessionId, mediaSessionId, _id, _internalSessionId) {
this._id = _id;
this.sessionId = sessionId;
this.mediaSessionId = mediaSessionId;
const namespace = "urn:x-cast:com.google.cast.media";
this.session = sessionMap.get(_internalSessionId);
this.session.createChannel(namespace);
this.channel = this.session.channelMap.get(namespace);
this.channel.on("message", data => {
if (data && data.type === 'MEDIA_STATUS'
&& data.status && data.status.length > 0) {
const status = data.status[0];
const messageData = {
currentTime: status.currentTime
, _lastCurrentTime: Date.now() / 1000
, customData: status.customData
, _volumeLevel: status.volume.level
, _volumeMuted: status.volume.muted
, playbackRate: status.playbackRate
, playerState: status.playerState
, repeatMode: status.repeatMode
};
if (status.media) {
messageData.media = status.media;
}
if (status.mediaSessionId) {
messageData.mediaSessionId = status.mediaSessionId;
}
this.sendMessage("shim:media/update", messageData);
// Update ID
if (status.mediaSessionId) {
this.mediaSessionId = status.mediaSessionId;
}
}
});
}
sendMessage (subject, data = {}) {
sendMessage({
subject
, data
, _id: this._id
});
}
}

107
app/src/transforms.js Executable file
View File

@@ -0,0 +1,107 @@
"use strict";
const { Transform } = require("stream");
/**
* Takes a handler function that implements the transform
* and calls the transform callback.
*/
const response = (handler) => new Transform({
readableObjectMode: true
, writableObjectMode: true
, transform (chunk, encoding, callback) {
Promise.resolve(handler(chunk, callback))
.then(response => {
if (response) {
callback(null, response);
} else {
callback(null);
}
});
}
});
/**
* Takes input, decodes the message string, parses as JSON
* and outputs the parsed result.
*/
const decode = new Transform({
readableObjectMode: true
, transform (chunk, encoding, callback) {
// Setup persistent data
if (!this.hasOwnProperty("buf")
&& !this.hasOwnProperty("message_length")) {
this.buf = new Buffer(0);
this.message_length = null;
}
// Append next chunk to buffer
this.buf = Buffer.concat([ this.buf, chunk ]);
while (true) {
if (this.message_length === null) {
if (this.buf.length >= 4) {
// Read message length
this.message_length = this.buf.readUInt32LE(0);
// Offset buffer
this.buf = this.buf.slice(4);
continue;
}
} else {
if (this.buf.length >= this.message_length) {
const message = JSON.parse(this.buf.slice(
0, this.message_length));
this.push(message);
// Cleanup persistent data
this.buf = this.buf.slice(this.message_length);
this.message_length = null;
// Parse next message
continue;
}
}
// No complete messages left
callback();
break;
}
}
});
/**
* Takes input, encodes the message length and content and
* outputs the encoded result.
*/
const encode = new Transform({
writableObjectMode: true
, transform (chunk, encoding, callback) {
const message_length = new Buffer(4);
const message = new Buffer(JSON.stringify(chunk));
// Write message length
message_length.writeUInt32LE(message.length, 0);
// Output joined message length and content
callback(null, Buffer.concat([message_length, message]));
}
});
module.exports = {
response
, decode
, encode
};

3
app/uninstall.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/sh
# TODO

5190
ext/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
ext/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"scripts": {
"build": "webpack",
"watch": "webpack -d --watch"
},
"devDependencies": {
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-do-expressions": "^6.22.0",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
"copy-webpack-plugin": "^4.3.1",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"webpack": "^3.10.0"
},
"dependencies": {
"uuid": "^3.2.1"
}
}

View File

@@ -0,0 +1,19 @@
{
"extension_name": {
"message": "Caster"
}
, "extension_description": {
"message": ""
}
, "popup_cast_button_label": {
"message": "Cast"
}
, "popup_casting_button_label": {
"message": "Casting"
}
, "context_media_cast": {
"message": "Cast..."
}
}

19
ext/src/compat/youtube.js Normal file
View File

@@ -0,0 +1,19 @@
"use strict";
function injectScript (url) {
const script = document.createElement("script");
script.src = url;
script.addEventListener("load", ev => {
script.remove();
});
document.documentElement.appendChild(script);
}
injectScript(browser.runtime.getURL("shim/bundle.js"));
//injectScript("https://s.ytimg.com/yts/jsbin/www-tampering-vflyYlECh/www-tampering.js");
//injectScript("https://s.ytimg.com/yts/jsbin/www-prepopulator-vfl8hLntF/www-prepopulator.js");
//injectScript("https://s.ytimg.com/yts/jsbin/webcomponents-lite.min-vfl2VqBkx/webcomponents-lite.min.js");
console.log(script);

12
ext/src/content.js Normal file
View File

@@ -0,0 +1,12 @@
"use strict";
document.addEventListener("__castMessageResponse", ev => {
browser.runtime.sendMessage(ev.detail);
})
browser.runtime.onMessage.addListener(message => {
const event = new CustomEvent("__castMessage", {
detail: JSON.stringify(message)
});
document.dispatchEvent(event);
});

3
ext/src/contentSetup.js Normal file
View File

@@ -0,0 +1,3 @@
"use strict";
window.wrappedJSObject.chrome = cloneInto({}, window);

11
ext/src/lib/utils.js Normal file
View File

@@ -0,0 +1,11 @@
"use strict";
export function cloneIntoWithProto (obj, destination) {
const ret = cloneInto(obj, destination);
for (const key of Object.getOwnPropertyNames(obj.__proto__)) {
exportFunction(obj.__proto__[key].bind(obj), ret, { defineAs: key });
}
return ret;
}

211
ext/src/main.js Executable file
View File

@@ -0,0 +1,211 @@
"use strict";
const _ = browser.i18n.getMessage;
// Google-hosted API loader script
const SENDER_SCRIPT_URL =
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js";
const SENDER_SCRIPT_FRAMEWORK_URL =
`${SENDER_SCRIPT_URL}?loadCastFramework=1`;
/**
* Sender applications load a cast_sender.js script that
* functions as a loader for the internal chrome-extension:
* hosted script.
*
* We can redirect this and inject our own script to setup
* the API shim.
*/
browser.webRequest.onBeforeRequest.addListener(
async details => {
console.log(details);
switch (details.url) {
case SENDER_SCRIPT_URL:
// Content/Page script bridge
await browser.tabs.executeScript(details.tabId, {
file: "content.js"
, frameId: details.frameId
, runAt: "document_start"
});
return {
redirectUrl: browser.runtime.getURL("shim/bundle.js")
};
case SENDER_SCRIPT_FRAMEWORK_URL:
// TODO: implement cast.framework
return {
cancel: true
};
}
}
, { urls: [
SENDER_SCRIPT_URL
, SENDER_SCRIPT_FRAMEWORK_URL
]}
, [ "blocking" ]);
// Defines window.chrome for site compatibility
browser.contentScripts.register({
allFrames: true
, js: [{ file: "contentSetup.js" }]
, matches: [ "<all_urls>" ]
, runAt: "document_start"
});
// YouTube compat shim
browser.contentScripts.register({
allFrames: true
, js: [{ file: "compat/youtube.js" }]
, matches: [ "*://www.youtube.com/*" ]
, runAt: "document_start"
});
// <video>/<audio> "Cast..." context menu item
browser.menus.create({
contexts: [ "audio", "video" ]
, id: "contextCastMedia"
, targetUrlPatterns: [
"http://*/*"
, "https://*/*"
]
, title: _("context_media_cast")
});
browser.menus.onClicked.addListener(async (info, tab) => {
const { frameId } = info;
// Pass media URL to media sender app
await browser.tabs.executeScript(tab.id, {
code: `const srcUrl = "${info.srcUrl}";`
, frameId
});
// Load app and sender API shim
await browser.tabs.executeScript(tab.id, { file: "content.js" , frameId })
await browser.tabs.executeScript(tab.id, { file: "mediaCast.js" , frameId });
await browser.tabs.executeScript(tab.id, { file: "shim/bundle.js" , frameId });
});
const bridgeMap = new Map();
/**
* Initializes native application and handles message
* forwarding.
*/
function initBridge (tabId, frameId) {
const port = browser.runtime.connectNative("caster_bridge");
bridgeMap.set(tabId, port);
port.onMessage.addListener(message => {
// Forward shim: messages
if (message.subject.startsWith("shim:")) {
browser.tabs.sendMessage(tabId, message, {
frameId
});
return;
}
});
}
let popupTabId;
let popupOpenerTabId;
/**
* Creates popup window for cast destination selection.
* Refocusing other browser windows causes the popup window
* to close and returns an API error (TODO).
*/
async function openPopup (tabId) {
const width = 350;
const height = 200;
// Current window to base centered position on
const win = await browser.windows.getCurrent();
// Top(mid)-center position
const centerX = win.left + (win.width / 2);
const centerY = win.top + (win.height / 3);
const left = Math.floor(centerX - (width / 2));
const top = Math.floor(centerY - (height / 2));
const popup = await browser.windows.create({
url: "popup/index.html"
, type: "popup"
, width
, height
, left
, top
});
// Store popup details for message forwarding
popupTabId = popup.tabs[0].id;
popupOpenerTabId = tabId;
// Size/position not set correctly on creation (bug?)
await browser.windows.update(popup.id, {
width
, height
, left
, top
});
// Close popup on other browser window focus
browser.windows.onFocusChanged.addListener(function listener (id) {
if (id !== browser.windows.WINDOW_ID_NONE
&& id === win.id) {
browser.windows.onFocusChanged.removeListener(listener);
browser.windows.remove(popup.id);
}
});
}
// Extension messages
browser.runtime.onMessage.addListener(async (message, sender, respond) => {
const tabId = sender.tab.id;
const { frameId } = sender.tab;
// Forward bridge: messages
if (message.subject.startsWith("bridge:")) {
bridgeMap.get(tabId).postMessage(message);
return;
}
// Forward shim: messages
if (message.subject.startsWith("shim:")) {
browser.tabs.sendMessage(popupOpenerTabId, message, { frameId });
return;
}
// Forward popup messages
if (message.subject.startsWith("popup:")) {
if (popupTabId) {
try {
browser.tabs.sendMessage(popupTabId, message);
} catch (err) {
// Popup is closed
}
}
return;
}
switch (message.subject) {
case "main:initialize":
initBridge(tabId);
break;
case "main:openPopup": {
await openPopup(tabId);
break;
}
}
});

34
ext/src/manifest.json Executable file
View File

@@ -0,0 +1,34 @@
{
"name": "__MSG_extension_name__"
, "description": "__MSG_extension_description__"
, "version": "0.0.1"
, "applications": {
"gecko": {
"id": "caster@matt.tf"
, "strict_min_version": "57.0"
}
}
, "browser_action": {
"default_popup": "popup/index.html"
}
, "background": {
"scripts": [ "main.js" ]
}
, "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'"
, "default_locale": "en"
, "manifest_version": 2
, "permissions": [
"menus"
, "nativeMessaging"
, "webNavigation"
, "webRequest"
, "webRequestBlocking"
, "<all_urls>"
]
, "web_accessible_resources": [
"shim/bundle.js"
, "dm.js"
]
}

253
ext/src/mediaCast.js Normal file
View File

@@ -0,0 +1,253 @@
"use strict";
let chrome;
let logMessage;
let session;
let currentMedia;
let mediaElement = document.querySelector(`[src="${srcUrl}"]`);
// TODO: Fix this broken mess
let ignoreMediaEvents = false;
function silent (fn) {
ignoreMediaEvents = true;
fn();
}
mediaElement.addEventListener("play", () => {
if (ignoreMediaEvents) {
ignoreMediaEvents = false;
return;
}
currentMedia.play(null
, onMediaPlaySuccess
, onMediaPlayError);
});
mediaElement.addEventListener("pause", () => {
if (ignoreMediaEvents) {
ignoreMediaEvents = false;
return;
}
currentMedia.pause(null
, onMediaPauseSuccess
, onMediaPauseError);
});
mediaElement.addEventListener("suspend", () => {
if (ignoreMediaEvents) return;
/*currentMedia.stop(null
, onMediaStopSuccess
, onMediaStopError);*/
});
mediaElement.addEventListener("seeking", () => {
if (ignoreMediaEvents) {
ignoreMediaEvents = false;
return;
}
const seekRequest = new chrome.cast.media.SeekRequest();
seekRequest.currentTime = mediaElement.currentTime;
currentMedia.seek(seekRequest
, onMediaSeekSuccess
, onMediaSeekError);
});
mediaElement.addEventListener("ratechange", () => {
if (ignoreMediaEvents) {
ignoreMediaEvents = false;
return;
}
currentMedia._sendMediaMessage({
type: "SET_PLAYBACK_RATE"
, playbackRate: mediaElement.playbackRate
});
});
mediaElement.addEventListener("volumechange", () => {
if (ignoreMediaEvents) {
ignoreMediaEvents = false;
return;
}
const newVolume = new chrome.cast.Volume(
currentMedia.volume
, currentMedia.muted);
const volumeRequest = new chrome.cast.media.VolumeRequest(newVolume);
logMessage("Volume change");
currentMedia.setVolume(volumeRequest);
});
function onRequestSessionSuccess (session_) {
logMessage("onRequestSessionSuccess");
session = session_;
const mediaUrl = new URL(srcUrl);
const mediaInfo = new chrome.cast.media.MediaInfo(mediaUrl.href);
// Media metadata (title/poster)
mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata();
mediaInfo.metadata.metadataType = chrome.cast.media.MetadataType.GENERIC;
mediaInfo.metadata.title = mediaUrl.pathname;
if (mediaElement.poster) {
mediaInfo.metadata.images = [
new chrome.cast.Image(mediaElement.poster)
];
}
const loadRequest = new chrome.cast.media.LoadRequest(mediaInfo);
loadRequest.autoplay = false;
session.loadMedia(loadRequest
, onLoadMediaSuccess
, onLoadMediaError);
}
function onRequestSessionError () {
logMessage("onRequestSessionError");
}
function sessionListener (session) {
logMessage("sessionListener");
}
function receiverListener (availability) {
logMessage("receiverListener");
if (availability === chrome.cast.ReceiverAvailability.AVAILABLE) {
chrome.cast.requestSession(
onRequestSessionSuccess
, onRequestSessionError);
}
}
function onInitializeSuccess () {
logMessage("onInitializeSuccess");
}
function onInitializeError () {
logMessage("onInitializeError");
}
function onLoadMediaSuccess (media) {
logMessage("onLoadMediaSuccess");
currentMedia = media;
currentMedia.addUpdateListener(() => {
console.log(currentMedia);
// PlayerState
const localPlayerState = mediaElement.paused
? chrome.cast.media.PlayerState.PAUSED
: chrome.cast.media.PlayerState.PLAYING;
if (localPlayerState !== currentMedia.playerState) {
switch (currentMedia.playerState) {
case chrome.cast.media.PlayerState.PLAYING:
silent(() => {
mediaElement.play();
});
break;
case chrome.cast.media.PlayerState.PAUSED:
silent(() => {
mediaElement.pause();
});
break;
}
}
// RepeatMode
const localRepeatMode = mediaElement.loop
? chrome.cast.media.RepeatMode.SINGLE
: chrome.cast.media.RepeatMode.OFF;
if (localRepeatMode !== currentMedia.repeatMode) {
switch (currentMedia.repeatMode) {
case chrome.cast.media.RepeatMode.SINGLE:
mediaElement.loop = true;
break;
case chrome.cast.media.RepeatMode.OFF:
mediaElement.loop = false;
break;
}
}
// currentTime
if (currentMedia.currentTime !== mediaElement.currentTime) {
silent(() => {
mediaElement.currentTime = currentMedia.currentTime;
});
}
});
}
function onLoadMediaError () {
logMessage("onLoadMediaError");
}
/* play */
function onMediaPlaySuccess () {
logMessage("onMediaPlaySuccess");
}
function onMediaPlayError (err) {
logMessage("onMediaPlayError");
}
/* pause */
function onMediaPauseSuccess () {
logMessage("onMediaPauseSuccess");
}
function onMediaPauseError (err) {
logMessage("onMediaPauseError");
}
/* stop */
function onMediaStopSuccess () {
logMessage("onMediaStopSuccess");
}
function onMediaStopError (err) {
logMessage("onMediaStopError");
}
/* seek */
function onMediaSeekSuccess () {
logMessage("onMediaSeekSuccess");
}
function onMediaSeekError (err) {
logMessage("onMediaSeekError");
}
window.__onGCastApiAvailable = function (loaded, errorInfo) {
if (!loaded) {
logMessage("__onGCastApiAvailable error");
return;
}
chrome = window.chrome;
logMessage = chrome.cast.logMessage;
logMessage("__onGCastApiAvailable success");
const sessionRequest = new chrome.cast.SessionRequest(
chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID);
const apiConfig = new chrome.cast.ApiConfig(sessionRequest
, sessionListener
, receiverListener);
chrome.cast.initialize(apiConfig
, onInitializeSuccess
, onInitializeError);
};

52
ext/src/popup/index.css Executable file
View File

@@ -0,0 +1,52 @@
body {
background: -moz-dialog;
color: -moz-dialogtext;
margin: initial;
font: message-box;
}
.receivers {
list-style: none;
margin: initial;
padding: initial;
}
.receiver {
column-gap: 0.75em;
display: grid;
flex-direction: column;
grid-template-columns: 1fr min-content;
grid-template-rows: min-content min-content 1fr;
grid-template-areas:
"name connect"
"address connect"
". connect";
justify-content: center;
padding: 0.75em 1em;
position: relative;
}
.receiver:not(:last-child) {
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
}
.receiver-name,
.receiver-address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.receiver-name {
font-size: 1.1em;
grid-area: name;
}
.receiver-address {
color: GrayText;
grid-area: address;
}
.receiver-connect {
grid-area: connect;
justify-self: end;
min-width: 100px;
}

11
ext/src/popup/index.html Executable file
View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="index.css">
<script src="bundle.js" defer></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

146
ext/src/popup/index.js Executable file
View File

@@ -0,0 +1,146 @@
"use strict";
import React, { Component } from "react";
import ReactDOM from "react-dom";
const _ = browser.i18n.getMessage;
let winWidth = 350;
let winHeight = 200;
let frameHeight;
let frameWidth;
class App extends Component {
constructor () {
super();
this.state = {
receivers: []
, isLoading: false
};
// Store window ref
browser.windows.getCurrent().then(win => {
this.win = win;
frameHeight = win.height - window.innerHeight;
frameWidth = win.width - window.innerWidth;
});
}
componentDidMount () {
browser.runtime.sendMessage({
subject: "shim:popupReady"
});
browser.runtime.onMessage.addListener(message => {
switch (message.subject) {
case "popup:populate":
this.setState({
receivers: message.data
});
winHeight = document.body.clientHeight + frameHeight;
browser.windows.update(this.win.id, {
height: winHeight
});
break;
case "popup:close":
window.close();
break;
}
});
}
onCast (receiver) {
this.setState({
isLoading: true
});
browser.runtime.sendMessage({
subject: "shim:selectReceiver"
, data: receiver
});
}
render () {
return (
<ul className="receivers">
{ this.state.receivers.map(receiver => {
return (
<Receiver receiver={receiver}
onCast={this.onCast.bind(this)}
isLoading={this.state.isLoading} />
);
})}
</ul>
);
}
}
class Receiver extends Component {
constructor () {
super();
this.state = {
isLoading: false
, ellipsis: ""
};
}
onClick () {
this.props.onCast(this.props.receiver);
this.setState({
isLoading: true
});
setInterval(() => {
this.setState({
ellipsis: do {
if (this.state.ellipsis === "") ".";
else if (this.state.ellipsis === ".") "..";
else if (this.state.ellipsis === "..") "...";
else if (this.state.ellipsis === "...") "";
}
});
}, 500);
}
render () {
return (
<li className="receiver">
<div className="receiver-name">
{ this.props.receiver.friendlyName }
</div>
<div className="receiver-address">
{ `${this.props.receiver._address}:${this.props.receiver._port}` }
</div>
<button className="receiver-connect"
onClick={this.onClick.bind(this)}
disabled={this.props.isLoading}>
{ do {
if (this.state.isLoading) {
_("popup_casting_button_label") +
(this.state.isLoading
? this.state.ellipsis
: "" )
} else {
_("popup_cast_button_label")
}
}}
</button>
</li>
);
}
}
ReactDOM.render(
<App />
, document.querySelector("#root"));

View File

@@ -0,0 +1,16 @@
"use strict";
export default class ApiConfig {
constructor (
sessionRequest
, sessionListener
, receiverListener
, opt_autoJoinPolicy
, opt_defaultActionPolicy) {
this.autoJoinPolicy
this.receiverListener = receiverListener;
this.sessionListener = sessionListener;
this.sessionRequest = sessionRequest;
}
};

View File

@@ -0,0 +1,12 @@
"use strict";
// https://developers.google.com/cast/docs/reference/chrome/chrome.cast.DialRequest
export default class DialRequest {
constructor (
appName
, opt_launchParameter = null) {
this.appName = appName;
this.launchParameter = opt_launchParameter;
}
};

View File

@@ -0,0 +1,14 @@
"use strict";
// https://developers.google.com/cast/docs/reference/chrome/chrome.cast.Error
export default class Error {
constructor (
code
, opt_description = null
, opt_details = null) {
this.code = code;
this.description = opt_description;
this.details = opt_details;
}
};

View File

@@ -0,0 +1,11 @@
"use strict";
// https://developers.google.com/cast/docs/reference/chrome/chrome.cast.Image
export default class Image {
width = null;
height = null;
constructor (url) {
this.url = url;
}
};

View File

@@ -0,0 +1,24 @@
"use strict";
import { Capability
, ReceiverType } from "../enums";
// https://developers.google.com/cast/docs/reference/chrome/chrome.cast.Receiver
export default class Receiver {
constructor (
label
, friendlyName
, opt_capabilities = [
Capability.VIDEO_OUT
, Capability.AUDIO_OUT ]
, opt_volume = null) {
this.capabilities = opt_capabilities;
this.displayStatus = null;
this.friendlyName = friendlyName;
this.isActiveInput = null;
this.label = label;
this.receiverType = ReceiverType.CAST;
this.volume = opt_volume;
}
};

View File

@@ -0,0 +1,10 @@
"use strict";
// https://developers.google.com/cast/docs/reference/chrome/chrome.cast.ReceiverDisplayStatus
export default class ReceiverDisplayStatus {
constructor (statusText, appImages) {
this.appImages = appImages;
this.showStop = null;
this.statusText = statusText;
}
};

View File

@@ -0,0 +1,10 @@
"use strict";
// https://developers.google.com/cast/docs/reference/chrome/chrome.cast.SenderApplication
export default class SenderApplication {
constructor (platform) {
this.packageId = null;
this.platform = platform;
this.url = null;
}
};

View File

@@ -0,0 +1,319 @@
"use strict";
import _Error from "./Error";
import Volume from "./Volume";
import Media from "../../media/classes/Media";
import { SessionStatus
, ErrorCode
, VolumeControlType } from "../enums";
import { onMessage, sendMessage } from "../../messageBridge";
import uuid from "uuid/v1";
export default class Session {
constructor (
sessionId
, appId
, displayName
, appImages
, receiver
, successCallback) {
this._id = uuid();
this._messageListeners = new Map();
this._updateListeners = new Set();
this._sendMessageCallbacks = new Map();
this._setReceiverMutedCallbacks = new Map();
this._setReceiverVolumeLevelCallbacks = new Map();
this._stopCallbacks = new Map();
this.sessionId = sessionId;
this.appId = appId;
this.appImages = appImages;
this.displayName = displayName;
this.receiver = receiver;
this.media = [];
this.namespaces = [];
this.senderApps = [];
this.status = SessionStatus.DISCONNECTED;
this.statusText = null;
this._sendMessage("bridge:bridgesession/initialize", {
address: receiver._address
, port: receiver._port
, appId
, sessionId
});
onMessage(message => {
// Filter other session messages
if (message._id && message._id !== this._id) {
return;
}
switch (message.subject) {
case "shim:session/stopped":
this.status = SessionStatus.STOPPED;
this._updateListeners.forEach(listener => listener());
break;
case "shim:session/connected":
this.status = SessionStatus.CONNECTED;
this.sessionId = message.data.sessionId;
this.namespaces = message.data.namespaces;
this.displayName = message.data.displayName;
this.statusText = message.data.statusText;
if (successCallback) {
successCallback(this);
}
break;
case "shim:session/updateStatus":
if (message.data.volume) {
if (!this.receiver.volume) {
const receiverVolume = new Volume(
message.data.volume.level
, message.data.volume.muted);
receiverVolume.controlType = message.data.volume.controlType;
receiverVolume.stepInterval = message.data.volume.stepInterval;
this.receiver.volume = receiverVolume;
} else {
this.receiver.volume.level = message.data.volume.level;
this.receiver.volume.muted = message.data.volume.muted;
}
}
break;
case "shim:session/impl_addMessageListener": {
const { namespace, data } = message.data;
this._messageListeners.get(namespace).forEach(
listener => listener(namespace, data));
break;
}
case "shim:session/impl_sendMessage": {
const { messageId, error } = message.data;
const [ successCallback, errorCallback ]
= this._sendMessageCallbacks.get(messageId)
if (error && errorCallback) {
errorCallback(new _Error(ErrorCode.SESSION_ERROR));
} else if (successCallback) {
successCallback();
}
this._sendMessageCallbacks.delete(messageId);
break;
}
case "shim:session/impl_setReceiverMuted": {
const { volumeId, error } = message.data;
const [ successCallback, errorCallback ]
= this._setReceiverMutedCallbacks.get(volumeId);
if (error && errorCallback) {
errorCallback(new _Error(ErrorCode.SESSION_ERROR));
} else if (successCallback) {
successCallback();
}
break;
this._setReceiverMutedCallbacks.delete(volumeId);
}
case "shim:session/impl_setReceiverVolumeLevel": {
const { volumeId, error } = message.data;
const [ successCallback, errorCallback ]
= this._setReceiverVolumeLevelCallbacks.get(volumeId);
if (error && errorCallback) {
errorCallback(new _Error(ErrorCode.SESSION_ERROR));
} else if (successCallback) {
successCallback();
}
this._setReceiverVolumeLevelCallbacks.delete(volumeId);
break;
}
case "shim:session/impl_stop": {
const { stopId, error } = message.data;
const [ successCallback, errorCallback ]
= this._stopCallbacks.get(stopId);
if (error && errorCallback) {
errorCallback(new _Error(ErrorCode.SESSION_ERROR));
} else {
this.status = SessionStatus.STOPPED;
this._updateListeners.forEach(listener => listener());
if (successCallback) {
successCallback();
}
}
this._stopCallbacks.delete(stopId);
break;
}
}
});
}
_sendMessage (subject, data = {}) {
sendMessage({
subject
, data
, _id: this._id
});
}
addMediaListener (listener) {
console.info("STUB :: Session#addMediaListener")
}
addMessageListener (namespace, listener) {
if (!this._messageListeners.has(namespace)) {
this._messageListeners.set(namespace, new Set());
}
this._messageListeners.get(namespace).add(listener);
this._sendMessage("bridge:bridgesession/impl_addMessageListener", {
namespace
});
}
addUpdateListener (listener) {
this._updateListeners.add(listener);
}
leave (successCallback, errorCallback) {
const id = uuid();
this._sendMessage("bridge:bridgesession/impl_leave", { id });
this._leaveCallbacks.set(id, [
successCallback
, errorCallback
]);
}
loadMedia (loadRequest, successCallback, errorCallback) {
this.sendMediaMessage({
type: "LOAD"
, requestId: 0
, media: loadRequest.media
, activeTrackIds: loadRequest.activeTrackIds || []
, autoplay: loadRequest.autoplay || false
, currentTime: loadRequest.currentTime || 0
, customData: loadRequest.customData || {}
, repeatMode: "REPEAT_OFF"
});
let hasResponded = false;
this.addMessageListener("urn:x-cast:com.google.cast.media"
, (namespace, data) => {
if (hasResponded) return;
const mediaObject = JSON.parse(data);
if (mediaObject.status && mediaObject.status.length > 0) {
hasResponded = true;
const media = new Media(
this.sessionId
, mediaObject.status[0].mediaSessionId
, this._id);
media.media = loadRequest.media;
this.media = [ media ];
media.play();
successCallback(media);
} else {
errorCallback(new _Error(ErrorCode.SESSION_ERROR));
}
})
}
queueLoad () {
console.info("STUB :: Session#queueLoad");
}
removeMediaListener (listener) {
console.info("STUB :: Session#removeMediaListener");
}
removeMessageListener (namespace, listener) {
this._messageListeners.get(namespace).delete(listener);
}
removeUpdateListener (namespace, listener) {
this._updateListeners.delete(listener);
}
sendMediaMessage (message) {
this.sendMessage(
"urn:x-cast:com.google.cast.media"
, message
, () => {}
, () => {});
}
sendMessage (namespace, message, successCallback, errorCallback) {
const messageId = uuid();
this._sendMessage("bridge:bridgesession/impl_sendMessage", {
namespace
, message
, messageId
});
this._sendMessageCallbacks.set(messageId, [
successCallback
, errorCallback
]);
}
setReceiverMuted (muted, successCallback, errorCallback) {
const volumeId = uuid();
this._sendMessage("bridge:bridgesession/impl_setReceiverMuted", {
muted
, volumeId
});
this._setReceiverMutedCallbacks.set(volumeId, [
successCallback
, errorCallback
]);
}
setReceiverVolumeLevel (newLevel, successCallback, errorCallback) {
const volumeId = uuid();
this._sendMessage("bridge:bridgesession/impl_setReceiverVolumeLevel", {
newLevel
, volumeId
});
this._setReceiverVolumeLevelCallbacks.set(volumeId, [
successCallback
, errorCallback
]);
}
stop (successCallback, errorCallback) {
const stopId = uuid();
this._sendMessage("bridge:bridgesession/impl_stop", { stopId });
this._stopCallbacks.set(stopId, [
successCallback
, errorCallback
]);
}
}

View File

@@ -0,0 +1,20 @@
"use strict";
import { Capability } from "../enums";
import { requestSession as requestSessionTimeout } from "../../timeout.js";
// https://developers.google.com/cast/docs/reference/chrome/chrome.cast.SessionRequest
export default class SessionRequest {
constructor (
appId
, opt_capabilities = [
Capability.VIDEO_OUT
, Capability.AUDIO_OUT ]
, opt_timeout = null) {
this.appId = appId;
this.capabilities = opt_capabilities;
this.language = null;
this.requestSessionTimeout = requestSessionTimeout;
}
};

View File

@@ -0,0 +1,10 @@
"use strict";
import * as timeouts from "../../timeout.js";
// https://developers.google.com/cast/docs/reference/chrome/chrome.cast.Timeout
export default class Timeout {
constructor () {
Object.assign(this, timeouts);
}
};

View File

@@ -0,0 +1,13 @@
"use strict";
import { VolumeControlType } from "../enums";
// https://developers.google.com/cast/docs/reference/chrome/chrome.cast.Volume
export default class Volume {
constructor (
opt_level = null
, opt_muted = null) {
this.level = opt_level;
this.muted = opt_muted;
}
};

View File

@@ -0,0 +1,28 @@
"use strict";
import ApiConfig from "./ApiConfig";
import DialRequest from "./DialRequest";
import Error_ from "./Error";
import Image_ from "./Image";
import Receiver from "./Receiver";
import ReceiverDisplayStatus from "./ReceiverDisplayStatus";
import SenderApplication from "./SenderApplication";
import Session from "./Session";
import SessionRequest from "./SessionRequest";
import Timeout from "./Timeout";
import Volume from "./Volume";
export default {
AutoJoinPolicy
, Capability
, DefaultActionPolicy
, DialAppState
, ErrorCode
, ReceiverAction
, ReceiverAvailability
, ReceiverType
, SenderPlatform
, SessionStatus
, VolumeControlType
};

View File

@@ -0,0 +1,74 @@
"use strict";
export const AutoJoinPolicy = {
TAB_AND_ORIGIN_SCOPED: "tab_and_origin_scoped"
, ORIGIN_SCOPED: "origin_scoped"
, PAGE_SCOPED: "page_scoped"
};
export const Capability = {
VIDEO_OUT: "video_out"
, AUDIO_OUT: "audio_out"
, VIDEO_IN: "video_in"
, AUDIO_IN: "audio_in"
, MULTIZONE_GROUP: "multizone_group"
};
export const DefaultActionPolicy = {
CREATE_SESSION: "create_session"
, CAST_THIS_TAB: "cast_this_tab"
};
export const DialAppState = {
RUNNING: "running"
, STOPPED: "stopped"
, ERROR: "error"
};
export const ErrorCode = {
CANCEL: "cancel"
, TIMEOUT: "timeout"
, API_NOT_INITIALIZED: "api_not_initialized"
, INVALID_PARAMETER: "invalid_parameter"
, EXTENSION_NOT_COMPATIBLE: "extension_not_compatible"
, EXTENSION_MISSING: "extension_missing"
, RECEIVER_UNAVAILABLE: "receiver_unavailable"
, SESSION_ERROR: "session_error"
, CHANNEL_ERROR: "channel_error"
, LOAD_MEDIA_FAILED: "load_media_failed"
};
export const ReceiverAction = {
CAST: "cast"
, STOP: "stop"
};
export const ReceiverAvailability = {
AVAILABLE: "available"
, UNAVAILABLE: "unavailable"
};
export const ReceiverType = {
CAST: "cast"
, DIAL: "dial"
, HANGOUT: "hangout"
, CUSTOM: "custom"
};
export const SenderPlatform = {
CHROME: "chrome"
, IOS: "ios"
, ANDROID: "android"
};
export const SessionStatus = {
CONNECTED: "connected"
, DISCONNECTED: "disconnected"
, STOPPED: "stopped"
};
export const VolumeControlType = {
ATTENUATION: "attenuation"
, FIXED: "fixed"
, MASTER: "master"
};

261
ext/src/shim/cast/index.js Executable file
View File

@@ -0,0 +1,261 @@
"use strict";
import ApiConfig from "./classes/ApiConfig";
import DialRequest from "./classes/DialRequest";
import Error_ from "./classes/Error";
import Image_ from "./classes/Image";
import Receiver from "./classes/Receiver";
import ReceiverDisplayStatus from "./classes/ReceiverDisplayStatus";
import SenderApplication from "./classes/SenderApplication";
import Session from "./classes/Session";
import SessionRequest from "./classes/SessionRequest";
import Timeout from "./classes/Timeout";
import Volume from "./classes/Volume";
import { AutoJoinPolicy
, Capability
, DefaultActionPolicy
, DialAppState
, ErrorCode
, ReceiverAction
, ReceiverAvailability
, ReceiverType
, SenderPlatform
, SessionStatus
, VolumeControlType } from "./enums";
import { requestSession as requestSessionTimeout } from "../timeout";
import state from "../state";
import { onMessage, sendMessage } from "../messageBridge";
const cast = {
// Enums
AutoJoinPolicy
, Capability
, DefaultActionPolicy
, DialAppState
, ErrorCode
, ReceiverAction
, ReceiverAvailability
, ReceiverType
, SenderPlatform
, SessionStatus
, VolumeControlType
// Classes
, ApiConfig
, DialRequest
, Error: Error_
, Image: Image_
, Receiver
, ReceiverDisplayStatus
, SenderApplication
, Session
, SessionRequest
, Timeout
, Volume
, timeout: new Timeout()
, isAvailable: true
, VERSION: [ 1, 2 ]
};
const receiverListeners = new Set();
let sessionSuccessCallback;
let sessionErrorCallback;
cast.addReceiverActionListener = (listener) => {
console.info("Caster (Debug): cast.addReceiverActionListener");
receiverListeners.add(listener);
};
cast.initialize = (
apiConfig
, successCallback
, errorCallback) => {
console.info("Caster (Debug): cast.initialize");
// Already initialized
if (state.apiConfig) {
errorCallback(new Error_(ErrorCode.RECEIVER_UNAVAILABLE));
return;
}
state.apiConfig = apiConfig;
sendMessage({
subject: "bridge:discover"
});
apiConfig.receiverListener(state.receiverList.length
? ReceiverAvailability.AVAILABLE
: ReceiverAvailability.UNAVAILABLE);
successCallback();
};
cast.logMessage = (message) => {
console.log("CAST MSG:", message);
};
cast.precache = (data) => {
console.info("STUB :: cast.precache");
};
cast.removeReceiverActionListener = (listener) => {
receiverListeners.delete(listener);
}
cast.requestSession = (
successCallback
, errorCallback
, opt_sessionRequest = state.apiConfig.sessionRequest) => {
console.info("Caster (Debug): cast.requestSession");
// Called before initialization
if (!state.apiConfig) {
errorCallback(new Error_(ErrorCode.API_NOT_INITIALIZED));
return;
}
// No available receivers
if (!state.receiverList.length) {
errorCallback(new Error_(ErrorCode.RECEIVER_UNAVAILABLE));
return;
}
sessionSuccessCallback = successCallback;
sessionErrorCallback = errorCallback;
// Open destination chooser
sendMessage({
subject: "main:openPopup"
});
};
cast.requestSessionById = (sessionId) => {
console.info("STUB :: cast.requestSessionById");
};
cast.setCustomReceivers = (receivers, successCallback, errorCallback) => {
console.info("STUB :: cast.setCustomReceivers");
};
cast.setPageContext = (win) => {
console.info("STUB :: cast.setPageContext");
};
cast.setReceiverDisplayStatus = (sessionId) => {
console.info("STUB :: cast.setReceiverDisplayStatus");
};
cast.unescape = (escaped) => unescape(escaped);
onMessage(message => {
switch (message.subject) {
/**
* Cast destination found (serviceUp). Set the API availability
* property and call the page event function (__onGCastApiAvailable).
*/
case "shim:serviceUp":
const receiver = new Receiver(
message.data.id
, message.data.friendlyName);
receiver._address = message.data.address;
receiver._port = message.data.port;
if (state.receiverList.find(r => r.label === receiver.label)) {
break;
}
state.receiverList.push(receiver);
// Notify listeners of new cast destination
state.apiConfig.receiverListener(ReceiverAvailability.AVAILABLE);
receiverListeners.forEach(
listener => listener(ReceiverAvailability.AVAILABLE));
break;
/**
* Cast destination lost (serviceDown). Remove from the receiver list
* and update availability state.
*/
case "shim:serviceDown":
state.receiverList = state.receiverList.filter(
receiver => receiver.label !== message.data.id);
if (state.receiverList.length === 0) {
state.apiConfig.receiverListener(
ReceiverAvailability.UNAVAILABLE);
}
break;
case "shim:selectReceiver":
console.info("Caster (Debug): Selected receiver");
const selectedReceiver = message.data;
const sessionConstructorArgs = [
state.sessionList.length // sessionId
, state.apiConfig.sessionRequest.appId // appId
, selectedReceiver.friendlyName // displayName
, [] // appImages
, selectedReceiver // receiver
, (session) => {
sendMessage({
subject: "popup:close"
});
state.apiConfig.sessionListener(session);
sessionSuccessCallback(session);
}
];
// If existing session active, stop it and start new one
if (state.sessionList.length) {
const lastSession
= state.sessionList[state.sessionList.length - 1];
if (lastSession.status !== SessionStatus.STOPPED) {
lastSession.stop(() => {
state.sessionList.push(new Session(
...sessionConstructorArgs));
});
break;
}
}
state.sessionList.push(new Session(...sessionConstructorArgs));
break;
/**
* Popup is ready to receive data to populate the cast destination
* chooser.
*/
case "shim:popupReady":
sendMessage({
subject: "popup:populate"
, data: state.receiverList
});
break;
}
});
// Trigger bridge mDNS discovery
sendMessage({
subject: "main:initialize"
});
export default cast;

18
ext/src/shim/index.js Executable file
View File

@@ -0,0 +1,18 @@
"use strict";
import cast from "./cast";
import media from "./media";
if (!window.chrome) {
window.chrome = {};
}
window.chrome.cast = cast;
window.chrome.cast.media = media;
// Call page's API loaded function if defined
const readyFunction = window.__onGCastApiAvailable;
console.log(readyFunction);
if (readyFunction && typeof readyFunction === "function") {
readyFunction(true);
}

View File

@@ -0,0 +1,8 @@
"use strict";
export default class EditTracksInfoRequest {
constructor (opt_activeTrackIds = null, opt_textTrackStyle = null) {
this.activeTrackIds = opt_activeTrackIds;
this.textTrackStyle = opt_textTrackStyle;
}
}

View File

@@ -0,0 +1,11 @@
"use strict";
export default class GenericMediaMetadata {
constructor () {
this.images = [];
this.metadataType = null;
this.releaseDate = null;
this.subtitle = null;
this.title = null;
}
}

View File

@@ -0,0 +1,8 @@
"use strict";
export default class GetStatusRequest {
constructor () {
castConsole.info('GetStatusRequest');
this.customData = {};
}
}

View File

@@ -0,0 +1,11 @@
"use strict";
export default class LoadRequest {
constructor (mediaInfo) {
this.activeTrackIds = [];
this.autoplay = false;
this.currentTime = 0;
this.customData = {};
this.media = mediaInfo;
}
}

View File

@@ -0,0 +1,202 @@
"use strict";
import Volume from "../../cast/classes/Volume";
import { PlayerState
, RepeatMode
, MediaCommand } from "../enums";
import _Error from "../../cast/classes/Error";
import { ErrorCode } from "../../cast/enums";
import { onMessage, sendMessage } from "../../messageBridge";
import uuid from "uuid/v1";
export default class Media {
constructor (sessionId, mediaSessionId, _internalSessionId) {
this._id = uuid();
this.activeTrackIds = [];
this.currentItemId = 1;
this.customData = {};
this.currentTime = 0;
this.idleReason = null;
this.items = [];
this.loadingItemId = null;
this.media = null;
this.mediaSessionId = mediaSessionId;
this.playbackRate = 1;
this.playerState = PlayerState.PAUSED;
this.preloadedItemId = null;
this.RepeatMode = RepeatMode.OFF;
this.sessionId = sessionId;
this.supportedMediaCommands = [
MediaCommand.PAUSE
, MediaCommand.SEEK
, MediaCommand.STREAM_VOLUME
, MediaCommand.STREAM_MUTE
];
this.volume = new Volume();
this._sendMessage("bridge:bridgemedia/initialize", {
sessionId
, mediaSessionId
, _internalSessionId
});
onMessage(message => {
if (!message._id || message._id !== this._id) {
return;
}
switch (message.subject) {
case "shim:media/update":
const status = message.data;
this.currentTime = status.currentTime;
this._lastCurrentTime = status._lastCurrentTime;
this.customData = status.customData;
this.volume = new Volume(
status._volumeLevel
, status._volumeMuted);
this.playbackRate = status.playbackRate;
this.playerState = status.playerState;
this.repeatMode = status.repeatMode;
if (status.media) {
this.media = status.media;
}
if (status.mediaSessionId) {
this.mediaSessionId = status.mediaSessionId;
}
// Call update listeners
this._updateListeners.forEach(listener => listener(true));
break;
case "shim:media/sendMediaMessageResponse":
const { messageId, error } = message.data;
const [ successCallback, errorCallback ]
= this._sendMediaMessageCallbacks.get(messageId);
if (error && errorCallback) {
errorCallback(new _Error(ErrorCode.SESSION_ERROR));
} else if (successCallback) {
successCallback();
}
break;
}
});
this._updateListeners = new Set();
this._sendMediaMessageCallbacks = new Map();
}
_sendMessage (subject, data) {
sendMessage({
subject
, data
, _id: this._id
});
}
_sendMediaMessage (message, successCallback, errorCallback) {
message.mediaSessionId = this.mediaSessionId;
message.requestId = 0;
message.sessionId = this.sessionId;
message.customData = null;
const messageId = uuid();
this._sendMediaMessageCallbacks.set(messageId, [
successCallback
, errorCallback
]);
this._sendMessage("bridge:bridgemedia/sendMediaMessage", {
message
, messageId
});
}
addUpdateListener (listener) {
this._updateListeners.add(listener);
}
editTracksInfo (editTracksInfoRequest, successCallback, errorCallback) {
console.log("STUB :: Media#editTracksInfo");
}
getEstimatedTime () {
if (!this.currentTime) return 0;
return this.currentTime + ((Date.now() / 1000) - this._lastCurrentTime);
}
getStatus (getStatusRequest, successCallback, errorCallback) {
this._sendMediaMessage({ type: "MEDIA_GET_STATUS" }
, successCallback, errorCallback);
}
pause (pauseRequest, successCallback, errorCallback) {
this._sendMediaMessage({ type: "PAUSE" }
, successCallback, errorCallback);
}
play (playRequest, successCallback, errorCallback) {
this._sendMediaMessage({ type: "PLAY" }
, successCallback, errorCallback);
}
queueAppendItem (item, successCallback, errorCallback) {
console.log("STUB :: Media#queueAppendItem");
}
queueInsertItems (queueInsertItemsRequest, successCallback, errorCallback) {
console.log("STUB :: Media#queueInsertItems");
}
queueJumpToItem (itemId, successCallback, errorCallback) {
console.log("STUB :: Media#queueJumpToItem");
}
queueMoveItemToNewIndex (itemId, newIndex, successCallback, errorCallback) {
console.log("STUB :: Media#queueMoveItemToNewIndex");
}
queueNext (successCallback, errorCallback) {
console.log("STUB :: Media#queueNext");
}
queuePrev (successCallback, errorCallback) {
console.log("STUB :: Media#queuePrev");
}
queueRemoveItem(itemId, successCallback, errorCallback) {
console.log("STUB :: Media#queueRemoveItem");
}
queueReorderItems (queueReorderItemsRequest, successCallback, errorCallback) {
console.log("STUB :: Media#queueReorderItems");
}
queueSetRepeatMode (repeatMode, successCallback, errorCallback) {
console.log("STUB :: Media#queueSetRepeatMode");
}
queueUpdateItems (queueUpdateItemsRequest, successCallback, errorCallback) {
console.log("STUB :: Media#queueUpdateItems");
}
removeUpdateListener (listener) {
this._updateListeners.delete(listener);
}
seek (seekRequest, successCallback, errorCallback) {
console.log(seekRequest);
this._sendMediaMessage({
type: "SEEK"
, currentTime: seekRequest.currentTime
}, successCallback, errorCallback);
}
setVolume (volumeRequest, successCallback, errorCallback) {
this._sendMediaMessage({
type: "SET_VOLUME"
, volume: volumeRequest.volume
}, successCallback, errorCallback);
}
stop (stopRequest, successCallback, errorCallback) {
this._sendMediaMessage({ type: "STOP" }
, successCallback, errorCallback);
}
supportsCommand (command) {
console.log("STUB :: Media#supportsCommand");
}
}

View File

@@ -0,0 +1,16 @@
"use strict";
import { StreamType } from "../enums";
export default class MediaInfo {
constructor (contentId, contentType) {
this.contentId = contentId;
this.contentType = contentType;
this.customData = {};
this.duration = null;
this.metadata = null;
this.streamType = StreamType.BUFFERED;
this.textTrackStyle = null;
this.tracks = [];
}
}

View File

@@ -0,0 +1,12 @@
"use strict";
export default class MovieMediaMetadata {
constructor () {
this.images = [];
this.metadataType = null;
this.releaseDate = null;
this.studio = null;
this.subtitle = null;
this.title = null;
}
}

View File

@@ -0,0 +1,17 @@
"use strict";
export default class MusicTrackMediaMetadata {
constructor () {
this.albumArtist = null;
this.albumName = null;
this.artist = null;
this.composer = null;
this.discNumber = null;
this.images = [];
this.metadataType = this.type = 3;
this.releaseDate = null;
this.songName = null;
this.title = null;
this.trackNumber = null;
}
}

View File

@@ -0,0 +1,7 @@
"use strict";
export default class PauseRequest {
constructor () {
this.customData = {};
}
}

View File

@@ -0,0 +1,16 @@
"use strict";
export default class PhotoMediaMetadata {
constructor () {
this.artist = null;
this.creationDateTime = null;
this.height = null;
this.images = [];
this.latitude = null;
this.location = null;
this.longitude = null;
this.metadataType = null;
this.title = null;
this.width = null;
}
}

View File

@@ -0,0 +1,7 @@
"use strict";
export default class PlayRequest {
constructor () {
this.customData = {};
}
}

View File

@@ -0,0 +1,9 @@
"use strict";
export default class QueueInsertItemsRequest {
constructor (itemsToInsert) {
this.customData = {};
this.insertBefore = null;
this.items = itemsToInsert;
}
}

View File

@@ -0,0 +1,13 @@
"use strict";
export default class QueueItem {
constructor (mediaInfo) {
this.activeTrackIds = [];
this.autoplay = false;
this.customData = {};
this.itemId = null;
this.media = mediaInfo;
this.preloadTime = 10;
this.startTime = 0;
}
}

View File

@@ -0,0 +1,12 @@
"use strict";
import { RepeatMode } from "../enums";
export default class QueueLoadRequest {
constructor (items) {
this.customData = {};
this.items = items;
this.repeatMode = RepeatMode.OFF;
this.startIndex = 0;
}
}

View File

@@ -0,0 +1,8 @@
"use strict";
export default class QueueRemoveItemsRequest {
constructor (itemIdsToRemove) {
this.customData = {};
this.itemIds = itemIdsToRemove;
}
}

View File

@@ -0,0 +1,10 @@
"use strict";
export default class QueueReorderItemsRequest {
constructor (itemIdsToReorder) {
this.customData = {};
this.type = "QUEUE_REORDER";
this.insertBefore = null;
this.itemIds = itemIdsToReorder;
}
}

View File

@@ -0,0 +1,11 @@
"use strict";
export default class QueueSetPropertiesRequest {
constructor () {
this.type = "QUEUE_UPDATE";
this.customData = {};
this.repeatMode = null;
this.sessionId = null;
this.requestId = null;
}
}

View File

@@ -0,0 +1,8 @@
"use strict";
export default class QueueUpdateItemsRequest {
constructor () {
this.customData = {};
this.items = [];
}
}

View File

@@ -0,0 +1,9 @@
"use strict";
export default class SeekRequest {
constructor () {
this.currentTime = null;
this.customData = {};
this.resumeState = null;
}
}

View File

@@ -0,0 +1,7 @@
"use strict";
export default class StopRequest {
constructor () {
this.customData = {};
}
}

View File

@@ -0,0 +1,18 @@
"use strict";
export default class TextTrackStyle {
constructor () {
this.backgroundColor = null;
this.customData = {};
this.edgeColor = null;
this.edgeType = null;
this.fontFamily = null;
this.fontGenericFamily = null;
this.fontScale = null;
this.fontStyle = null;
this.foregroundColor = null;
this.windowColor = null;
this.windowRoundedCornerRadius = null;
this.windowType = null;
}
}

View File

@@ -0,0 +1,14 @@
"use strict";
export default class Track {
constructor (trackId, trackType) {
this.customData = {};
this.language = null;
this.name = null;
this.subtype = null;
this.trackContentId = null;
this.trackContentType = null;
this.trackId = trackId;
this.type = trackType;
}
}

View File

@@ -0,0 +1,13 @@
"use strict";
export default class TvShowMediaMetadata {
constructor () {
this.episode = null;
this.images = [];
this.metadataType = null;
this.originalAirdate = null;
this.season = null;
this.seriesTitle = null;
this.title = null;
}
}

View File

@@ -0,0 +1,8 @@
"use strict";
export default class VolumeRequest {
constructor (volume) {
this.volume = volume;
this.customData = {};
}
}

View File

@@ -0,0 +1,92 @@
"use strict";
export const IdleReason = {
CANCELLED: "cancelled"
, INTERRUPTED: "interrupted"
, FINISHED: "finished"
, ERROR: "error"
};
export const MediaCommand = {
PAUSE: "pause"
, SEEK: "seek"
, STREAM_VOLUME: "stream_volume"
, STREAM_MUTE: "stream_mute"
};
export const MetadataType = {
GENERIC: "GENERIC"
, MOVIE: "MOVIE"
, TV_SHOW: "TV_SHOW"
, MUSIC_TRACK: "MUSIC_TRACK"
, PHOTO: "PHOTO"
};
export const PlayerState = {
IDLE: "IDLE"
, PLAYING: "PLAYING"
, PAUSED: "PAUSED"
, BUFFERING: "BUFFERING"
};
export const RepeatMode = {
OFF: "OFF"
, ALL: "ALL"
, SINGLE: "SINGLE"
, ALL_AND_SHUFFLE: "ALL_AND_SHUFFLE"
};
export const ResumeState = {
PLAYBACK_START: "PLAYBACK_START"
, PLAYBACK_PAUSE: "PLAYBACK_PAUSE"
};
export const StreamType = {
BUFFERED: "BUFFERED"
, LIVE: "LIVE"
, OTHER: "OTHER"
};
export const TextTrackEdgeType = {
NONE: "NONE"
, OUTLINE: "OUTLINE"
, DROP_SHADOW: "DROP_SHADOW"
, RAISED: "RAISED"
, DEPRESSED: "DEPRESSED"
};
export const TextTrackFontGenericFamily = {
SANS_SERIF: "SANS_SERIF"
, MONOSPACED_SANS_SERIF: "MONOSPACED_SANS_SERIF"
, SERIF: "SERIF"
, CASUAL: "CASUAL"
, CURSIVE: "CURSIVE"
, SMALL_CAPITALS: "SMALL_CAPITALS"
};
export const TextTrackFontStyle = {
NORMAL: "NORMAL"
, BOLD: "BOLD"
, BOLD_ITALIC: "BOLD_ITALIC"
, ITALIC: "ITALIC"
};
export const TextTrackType = {
SUBTITLES: "SUBTITLES"
, CAPTIONS: "CAPTIONS"
, DESCRIPTIONS: "DESCRIPTIONS"
, CHAPTERS: "CHAPTERS"
, METADATA: "METADATA"
};
export const TextTrackWindowType = {
NONE: "NONE"
, NORMAL: "NORMAL"
, ROUNDED_CORNERS: "ROUNDED_CORNERS"
};
export const TrackType = {
TEXT: "TEXT"
, AUDIO: "AUDIO"
, VIDEO: "VIDEO"
};

86
ext/src/shim/media/index.js Executable file
View File

@@ -0,0 +1,86 @@
"use strict";
import EditTracksInfoRequest from "./classes/EditTracksInfoRequest";
import GenericMediaMetadata from "./classes/GenericMediaMetadata";
import GetStatusRequest from "./classes/GetStatusRequest";
import LoadRequest from "./classes/LoadRequest";
import Media from "./classes/Media";
import MediaInfo from "./classes/MediaInfo";
import MovieMediaMetadata from "./classes/MovieMediaMetadata";
import MusicTrackMediaMetadata from "./classes/MusicTrackMediaMetadata";
import PauseRequest from "./classes/PauseRequest";
import PhotoMediaMetadata from "./classes/PhotoMediaMetadata";
import PlayRequest from "./classes/PlayRequest";
import QueueInsertItemsRequest from "./classes/QueueInsertItemsRequest";
import QueueItem from "./classes/QueueItem";
import QueueLoadRequest from "./classes/QueueLoadRequest";
import QueueRemoveItemsRequest from "./classes/QueueRemoveItemsRequest";
import QueueReorderItemsRequest from "./classes/QueueReorderItemsRequest";
import QueueSetPropertiesRequest from "./classes/QueueSetPropertiesRequest";
import QueueUpdateItemsRequest from "./classes/QueueUpdateItemsRequest";
import SeekRequest from "./classes/SeekRequest";
import StopRequest from "./classes/StopRequest";
import TextTrackStyle from "./classes/TextTrackStyle";
import Track from "./classes/Track";
import TvShowMediaMetadata from "./classes/TvShowMediaMetadata";
import VolumeRequest from "./classes/VolumeRequest";
import { IdleReason
, MediaCommand
, MetadataType
, PlayerState
, RepeatMode
, ResumeState
, StreamType
, TextTrackEdgeType
, TextTrackFontGenericFamily
, TextTrackFontStyle
, TextTrackType
, TextTrackWindowType
, TrackType } from "./enums";
export default {
// Enums
IdleReason
, MediaCommand
, MetadataType
, PlayerState
, RepeatMode
, ResumeState
, StreamType
, TextTrackEdgeType
, TextTrackFontGenericFamily
, TextTrackFontStyle
, TextTrackType
, TextTrackWindowType
, TrackType
// Classes
, EditTracksInfoRequest
, GenericMediaMetadata
, GetStatusRequest
, LoadRequest
, Media
, MediaInfo
, MovieMediaMetadata
, MusicTrackMediaMetadata
, PauseRequest
, PhotoMediaMetadata
, PlayRequest
, QueueInsertItemsRequest
, QueueItem
, QueueLoadRequest
, QueueRemoveItemsRequest
, QueueReorderItemsRequest
, QueueSetPropertiesRequest
, QueueUpdateItemsRequest
, SeekRequest
, StopRequest
, TextTrackStyle
, Track
, TvShowMediaMetadata
, VolumeRequest
, DEFAULT_MEDIA_RECEIVER_APP_ID: "CC1AD845"
};

View File

@@ -0,0 +1,15 @@
"use strict";
export function onMessage (listener) {
document.addEventListener("__castMessage", ev => {
listener(JSON.parse(ev.detail));
});
}
export function sendMessage (message) {
const event = new CustomEvent("__castMessageResponse", {
detail: message
});
document.dispatchEvent(event);
}

10
ext/src/shim/state.js Executable file
View File

@@ -0,0 +1,10 @@
"use strict";
// Global API state
const state = {
apiConfig: null
, receiverList: []
, sessionList: []
};
export default state;

7
ext/src/shim/timeout.js Executable file
View File

@@ -0,0 +1,7 @@
"use strict";
export const leaveSession = 3000;
export const requestSession = 60000;
export const sendCustomMessage = 3000;
export const setReceiverVolume = 3000;
export const stopSession = 3000;

57
ext/webpack.config.js Executable file
View File

@@ -0,0 +1,57 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const webpack_copy = require("copy-webpack-plugin");
const include_path = path.resolve(__dirname, "src");
const output_path = path.resolve(__dirname, "dist");
module.exports = {
entry: {
"main" : `${include_path}/main.js`
, "popup/bundle" : `${include_path}/popup/index.js`
, "shim/bundle" : `${include_path}/shim/index.js`
, "content" : `${include_path}/content.js`
, "contentSetup" : `${include_path}/contentSetup.js`
, "mediaCast" : `${include_path}/mediaCast.js`
, "compat/youtube" : `${include_path}/compat/youtube.js`
}
, output: {
filename: "[name].js"
, path: `${output_path}`
}
, plugins: [
new webpack.optimize.UglifyJsPlugin()
//, new webpack.optimize.CommonsChunkPlugin("lib/init.bundle")
, new webpack.DefinePlugin({
"process.env.NODE_ENV": `"production"`
})
// Ext copy assets
, new webpack_copy([{
from: `${include_path}`
, to: `${output_path}`
, ignore: [ "*.js" ]
}])
]
, devtool: "source-map"
, module: {
loaders: [
{
test: /\.js/
, include: `${include_path}`
, loader: "babel-loader"
, options: {
presets: [ "react" ]
, plugins: [
"transform-class-properties"
, "transform-do-expressions"
, "transform-object-rest-spread"
]
}
}
]
}
};

61
webpack.config.js Normal file
View File

@@ -0,0 +1,61 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const webpack_copy = require("copy-webpack-plugin");
const include_path = path.resolve(__dirname, "src");
const output_path = path.resolve(__dirname, "dist");
module.exports = {
entry: {
"main" : `${include_path}/ext/main.js`
, "popup/bundle" : `${include_path}/ext/popup/index.js`
, "shim/bundle" : `${include_path}/ext/shim/index.js`
, "content" : `${include_path}/ext/content.js`
, "contentSetup" : `${include_path}/ext/contentSetup.js`
, "mediaCast" : `${include_path}/ext/mediaCast.js`
, "compat/youtube" : `${include_path}/ext/compat/youtube.js`
}
, output: {
filename: "[name].js"
, path: `${output_path}/ext`
}
, plugins: [
new webpack.optimize.UglifyJsPlugin()
//, new webpack.optimize.CommonsChunkPlugin("lib/init.bundle")
, new webpack.DefinePlugin({
"process.env.NODE_ENV": `"production"`
})
// Ext copy assets
, new webpack_copy([{
from: `${include_path}/ext`
, to: `${output_path}/ext`
, ignore: [ "*.js" ]
}, {
from: `${include_path}`
, to: `${output_path}`
, ignore: [ "app" ]
}])
]
, devtool: "source-map"
, module: {
loaders: [
{
test: /\.js/
, include: `${include_path}/ext`
, loader: "babel-loader"
, options: {
presets: [ "react" ]
, plugins: [
"transform-class-properties"
, "transform-do-expressions"
, "transform-object-rest-spread"
]
}
}
]
}
};