Home Assistant Cast

pull/3457/head
Paulus Schoutsen 2019-07-30 10:18:47 -07:00
parent 0544027c38
commit 2da844a1fb
48 changed files with 2709 additions and 12 deletions

View File

@ -0,0 +1,37 @@
// Run cast develop mode
const gulp = require("gulp");
require("./clean.js");
require("./translations.js");
require("./gen-icons.js");
require("./gather-static.js");
require("./webpack.js");
require("./service-worker.js");
require("./entry-html.js");
gulp.task(
"develop-cast",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "development";
},
"clean-cast",
gulp.parallel("gen-icons", "gen-index-cast-dev", "build-translations"),
"copy-static-cast",
"webpack-dev-server-cast"
)
);
gulp.task(
"build-cast",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-cast",
gulp.parallel("gen-icons", "build-translations"),
"copy-static-cast",
"webpack-prod-cast",
"gen-index-cast-prod"
)
);

View File

@ -15,3 +15,9 @@ gulp.task(
return del([config.demo_root, config.build_dir]); return del([config.demo_root, config.build_dir]);
}) })
); );
gulp.task(
"clean-cast",
gulp.parallel("clean-translations", function cleanOutputAndBuildDir() {
return del([config.cast_root, config.build_dir]);
})
);

View File

@ -14,6 +14,9 @@ const templatePath = (tpl) =>
const demoTemplatePath = (tpl) => const demoTemplatePath = (tpl) =>
path.resolve(config.demo_dir, "src/html/", `${tpl}.html.template`); path.resolve(config.demo_dir, "src/html/", `${tpl}.html.template`);
const castTemplatePath = (tpl) =>
path.resolve(config.cast_dir, "src/html/", `${tpl}.html.template`);
const readFile = (pth) => fs.readFileSync(pth).toString(); const readFile = (pth) => fs.readFileSync(pth).toString();
const renderTemplate = (pth, data = {}, pathFunc = templatePath) => { const renderTemplate = (pth, data = {}, pathFunc = templatePath) => {
@ -24,6 +27,9 @@ const renderTemplate = (pth, data = {}, pathFunc = templatePath) => {
const renderDemoTemplate = (pth, data = {}) => const renderDemoTemplate = (pth, data = {}) =>
renderTemplate(pth, data, demoTemplatePath); renderTemplate(pth, data, demoTemplatePath);
const renderCastTemplate = (pth, data = {}) =>
renderTemplate(pth, data, castTemplatePath);
const minifyHtml = (content) => const minifyHtml = (content) =>
minify(content, { minify(content, {
collapseWhitespace: true, collapseWhitespace: true,
@ -113,17 +119,64 @@ gulp.task("gen-index-app-prod", (done) => {
done(); done();
}); });
gulp.task("gen-index-demo-dev", (done) => { gulp.task("gen-index-cast-dev", (done) => {
// In dev mode we don't mangle names, so we hardcode urls. That way we can const contentReceiver = renderCastTemplate("receiver", {
// run webpack as last in watch mode, which blocks output. latestReceiverJS: "/frontend_latest/receiver.js",
const content = renderDemoTemplate("index", { });
latestDemoJS: "/frontend_latest/main.js", fs.outputFileSync(
path.resolve(config.cast_root, "receiver.html"),
contentReceiver
);
es5Compatibility: "/frontend_es5/compatibility.js", const contentFAQ = renderCastTemplate("launcher-faq", {
es5DemoJS: "/frontend_es5/main.js", latestLauncherJS: "/frontend_latest/launcher.js",
es5LauncherJS: "/frontend_es5/launcher.js",
});
fs.outputFileSync(path.resolve(config.cast_root, "faq.html"), contentFAQ);
const contentLauncher = renderCastTemplate("launcher", {
latestLauncherJS: "/frontend_latest/launcher.js",
es5LauncherJS: "/frontend_es5/launcher.js",
});
fs.outputFileSync(
path.resolve(config.cast_root, "index.html"),
contentLauncher
);
done();
}); });
fs.outputFileSync(path.resolve(config.demo_root, "index.html"), content); gulp.task("gen-index-cast-prod", (done) => {
const latestManifest = require(path.resolve(
config.cast_output,
"manifest.json"
));
const es5Manifest = require(path.resolve(
config.cast_output_es5,
"manifest.json"
));
const contentReceiver = renderCastTemplate("receiver", {
latestReceiverJS: latestManifest["receiver.js"],
});
fs.outputFileSync(
path.resolve(config.cast_root, "receiver.html"),
contentReceiver
);
const contentFAQ = renderCastTemplate("launcher-faq", {
latestLauncherJS: latestManifest["launcher.js"],
es5LauncherJS: es5Manifest["launcher.js"],
});
fs.outputFileSync(path.resolve(config.cast_root, "faq.html"), contentFAQ);
const contentLauncher = renderCastTemplate("launcher", {
latestLauncherJS: latestManifest["launcher.js"],
es5LauncherJS: es5Manifest["launcher.js"],
});
fs.outputFileSync(
path.resolve(config.cast_root, "index.html"),
contentLauncher
);
done(); done();
}); });

View File

@ -114,3 +114,15 @@ gulp.task("copy-static-demo", (done) => {
copyTranslations(paths.demo_static); copyTranslations(paths.demo_static);
done(); done();
}); });
gulp.task("copy-static-cast", (done) => {
// Copy app static files
fs.copySync(polyPath("public/static"), paths.cast_static);
// Copy cast static files
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_root);
copyMapPanel(paths.cast_static);
copyFonts(paths.cast_static);
copyTranslations(paths.cast_static);
done();
});

View File

@ -5,7 +5,11 @@ const webpack = require("webpack");
const WebpackDevServer = require("webpack-dev-server"); const WebpackDevServer = require("webpack-dev-server");
const log = require("fancy-log"); const log = require("fancy-log");
const paths = require("../paths"); const paths = require("../paths");
const { createAppConfig, createDemoConfig } = require("../webpack"); const {
createAppConfig,
createDemoConfig,
createCastConfig,
} = require("../webpack");
const handler = (done) => (err, stats) => { const handler = (done) => (err, stats) => {
if (err) { if (err) {
@ -114,3 +118,53 @@ gulp.task(
) )
) )
); );
gulp.task("webpack-dev-server-cast", () => {
const compiler = webpack([
createCastConfig({
isProdBuild: false,
latestBuild: false,
}),
createCastConfig({
isProdBuild: false,
latestBuild: true,
}),
]);
new WebpackDevServer(compiler, {
open: true,
watchContentBase: true,
contentBase: path.resolve(paths.cast_dir, "dist"),
}).listen(
8080,
// Accessible from the network, because that's how Cast hits it.
"0.0.0.0",
function(err) {
if (err) {
throw err;
}
// Server listening
log("[webpack-dev-server]", "http://localhost:8080");
}
);
});
gulp.task(
"webpack-prod-cast",
() =>
new Promise((resolve) =>
webpack(
[
createCastConfig({
isProdBuild: true,
latestBuild: false,
}),
createCastConfig({
isProdBuild: true,
latestBuild: true,
}),
],
handler(resolve)
)
)
);

View File

@ -14,4 +14,10 @@ module.exports = {
demo_static: path.resolve(__dirname, "../demo/dist/static"), demo_static: path.resolve(__dirname, "../demo/dist/static"),
demo_output: path.resolve(__dirname, "../demo/dist/frontend_latest"), demo_output: path.resolve(__dirname, "../demo/dist/frontend_latest"),
demo_output_es5: path.resolve(__dirname, "../demo/dist/frontend_es5"), demo_output_es5: path.resolve(__dirname, "../demo/dist/frontend_es5"),
cast_dir: path.resolve(__dirname, "../cast"),
cast_root: path.resolve(__dirname, "../cast/dist"),
cast_static: path.resolve(__dirname, "../cast/dist/static"),
cast_output: path.resolve(__dirname, "../cast/dist/frontend_latest"),
cast_output_es5: path.resolve(__dirname, "../cast/dist/frontend_es5"),
}; };

View File

@ -214,10 +214,56 @@ const createDemoConfig = ({ isProdBuild, latestBuild, isStatsBuild }) => {
}; };
}; };
const createCastConfig = ({ isProdBuild, latestBuild }) => {
const isStatsBuild = false;
const entry = {
launcher: "./cast/src/launcher/entrypoint.ts",
};
if (latestBuild) {
entry.receiver = "./cast/src/receiver/entrypoint.ts";
}
return {
mode: genMode(isProdBuild),
devtool: genDevTool(isProdBuild),
entry,
module: {
rules: [babelLoaderConfig({ latestBuild }), cssLoader, htmlLoader],
},
optimization: optimization(latestBuild),
plugins: [
new ManifestPlugin(),
new webpack.DefinePlugin({
__DEV__: !isProdBuild,
__BUILD__: JSON.stringify(latestBuild ? "latest" : "es5"),
__VERSION__: JSON.stringify(version),
__DEMO__: false,
__STATIC_PATH__: "/static/",
"process.env.NODE_ENV": JSON.stringify(
isProdBuild ? "production" : "development"
),
}),
...plugins,
].filter(Boolean),
resolve,
output: {
filename: genFilename(isProdBuild),
chunkFilename: genChunkFilename(isProdBuild, isStatsBuild),
path: path.resolve(
paths.cast_root,
latestBuild ? "frontend_latest" : "frontend_es5"
),
publicPath: latestBuild ? "/frontend_latest/" : "/frontend_es5/",
},
};
};
module.exports = { module.exports = {
resolve, resolve,
plugins, plugins,
optimization, optimization,
createAppConfig, createAppConfig,
createDemoConfig, createDemoConfig,
createCastConfig,
}; };

56
cast/README.md Normal file
View File

@ -0,0 +1,56 @@
# Home Assistant Cast
Home Assistant Cast is made up of two separate applications:
- Chromecast receiver application that can connect to Home Assistant and display relevant information.
- Launcher website that allows users to authorize with their Home Assistant installation and launch the receiver app on their Chromecast.
## Development
- Run `script/develop_cast` to launch the Cast receiver dev server. Keep this running.
- Navigate to http://localhost:8080 to start the launcher
- Debug the receiver running on the Chromecast via [chrome://inspect/#devices](chrome://inspect/#devices)
## Setting up development environment
### Registering development cast app
- Go to https://cast.google.com/publish and enroll your account for the Google Cast SDK (costs \$5)
- Register your Chromecast as a testing device by entering the serial
- Add new application -> Custom Receiver
- Name: Home Assistant Dev
- Receiver Application URL: http://IP-OF-DEV-MACHINE:8080/receiver.html
- Guest Mode: off
- Google Case for Audio: off
### Setting dev variables
Open `src/cast/const.ts` and change `CAST_DEV` to `true` and `CAST_DEV_APP_ID` to the ID of the app you just created.
### Changing configuration
In `configuration.yaml`, configure CORS for the HTTP integration:
```yaml
http:
cors_allowed_origins:
- https://cast.home-assistant.io
- http://IP-OF-DEV-MACHINE:8080
```
## Running development
```bash
cd cast
script/develop_cast
```
The launcher application will be accessible at [http://localhost:8080](http://localhost:8080) and the receiver application will be accessible at [http://localhost:8080/receiver.html](http://localhost:8080/receiver.html) (but only works if accessed by a Chromecast).
### Developing cast widgets in HA ui
If your work involves interaction with the Cast parts from the normal Home Assistant UI, you will need to have that development script running too (`script/develop`).
### Developing the cast demo
The cast demo is triggered from the Home Assistant demo. To work on that, you will also need to run the development script for the demo (`script/develop_demo`).

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

18
cast/public/manifest.json Normal file
View File

@ -0,0 +1,18 @@
{
"background_color": "#FFFFFF",
"description": "Show Home Assistant on your Chromecast or Google Assistant devices with a screen.",
"dir": "ltr",
"display": "standalone",
"icons": [
{
"src": "/images/ha-cast-icon.png",
"sizes": "512x512",
"type": "image/png"
}
],
"lang": "en-US",
"name": "Home Assistant Cast",
"short_name": "HA Cast",
"start_url": "/?homescreen=1",
"theme_color": "#03A9F4"
}

View File

@ -0,0 +1,3 @@
self.addEventListener("fetch", function(event) {
event.respondWith(fetch(event.request));
});

9
cast/script/build_cast Executable file
View File

@ -0,0 +1,9 @@
#!/bin/sh
# Build the cast receiver
# Stop on errors
set -e
cd "$(dirname "$0")/../.."
./node_modules/.bin/gulp build-cast

9
cast/script/develop_cast Executable file
View File

@ -0,0 +1,9 @@
#!/bin/sh
# Develop the cast receiver
# Stop on errors
set -e
cd "$(dirname "$0")/../.."
./node_modules/.bin/gulp develop-cast

3
cast/script/upload Executable file
View File

@ -0,0 +1,3 @@
# Run it twice, second time we just delete.
aws s3 sync dist s3://cast.home-assistant.io --acl public-read
aws s3 sync dist s3://cast.home-assistant.io --acl public-read --delete

View File

@ -0,0 +1,226 @@
<!DOCTYPE html>
<html>
<head>
<title>Home Assistant Cast - FAQ</title>
<link rel="icon" href="/images/ha-cast-icon.png" type="image/png" />
<%= renderTemplate('_style_base') %>
<style>
body {
background-color: #e5e5e5;
}
</style>
<meta property="fb:app_id" content="338291289691179" />
<meta property="og:title" content="FAQ - Home Assistant Cast" />
<meta property="og:site_name" content="Home Assistant Cast" />
<meta property="og:url" content="https://cast.home-assistant.io/" />
<meta property="og:type" content="website" />
<meta
property="og:description"
content="Frequently asked questions about Home Assistant Cast."
/>
<meta
property="og:image"
content="https://cast.home-assistant.io/images/google-nest-hub.png"
/>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@home_assistant" />
<meta name="twitter:title" content="FAQ - Home Assistant Cast" />
<meta
name="twitter:description"
content="Frequently asked questions about Home Assistant Cast."
/>
<meta
name="twitter:image"
content="https://cast.home-assistant.io/images/google-nest-hub.png"
/>
</head>
<body>
<%= renderTemplate('_js_base') %>
<script type="module" crossorigin="use-credentials">
import "<%= latestLauncherJS %>";
</script>
<script nomodule>
(function() {
// // Safari 10.1 supports type=module but ignores nomodule, so we add this check.
if (!isS101) {
_ls("/static/polyfills/custom-elements-es5-adapter.js");
_ls("<%= es5LauncherJS %>");
}
})();
</script>
<hc-layout subtitle="FAQ">
<style>
a {
color: var(--primary-color);
}
</style>
<div class="card-content">
<p><a href="/">&laquo; Back to Home Assistant Cast</a></p>
</div>
<div class="section-header">What is Home Assistant Cast?</div>
<div class="card-content">
<p>
Home Assistant Cast allows you to show your Home Assistant data on a
Chromecast device and allows you to interact with Home Assistant on
Google Assistant devices with a screen.
</p>
</div>
<div class="section-header">
What are the Home Assistant Cast requirements?
</div>
<div class="card-content">
<p>
Home Assistant Cast requires a Home Assistant installation that is
accessible via HTTPS (the url starts with "https://").
</p>
</div>
<div class="section-header">What is Home Assistant?</div>
<div class="card-content">
<p>
Home Assistant is worlds biggest open source home automation platform
with a focus on privacy and local control. You can install Home
Assistant for free.
</p>
<p>
<a href="https://www.home-assistant.io" target="_blank"
>Visit the Home Assistant website.</a
>
</p>
</div>
<div class="section-header" id="https">
Why does my Home Assistant needs to be served using HTTPS?
</div>
<div class="card-content">
<p>
The Chromecast only works with websites served over HTTPS. This means
that the Home Assistant Cast app that runs on your Chromecast is
served over HTTPS. Websites served over HTTPS are restricted on what
content can be accessed on websites served over HTTP. This is called
mixed active content (<a
href="https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content#Mixed_active_content"
target="_blank"
>learn more @ MDN</a
>).
</p>
<p>
The easiest way to get your Home Assistant installation served over
HTTPS is by signing up for
<a href="https://www.nabucasa.com" target="_blank"
>Home Assistant Cloud by Nabu Casa</a
>.
</p>
</div>
<div class="section-header">How does Home Assistant Cast work?</div>
<div class="card-content">
<p>
Home Assistant Cast is a receiver application for the Chromecast. When
loaded, it will make a direct connection to your Home Assistant
instance.
</p>
<p>
Home Assistant Cast is able to render any of your Lovelace views on
your Chromecast. Things that work in Lovelace in Home Assistant will
work in Home Assistant Cast:
</p>
<ul>
<li>Render Lovelace views, including custom cards</li>
<li>
Real-time data stream will ensure the UI always shows the latest
state of your house
</li>
<li>Navigate between views using navigate actions or weblinks</li>
<li>
Instant updates of the casted Lovelace UI when you update your
Lovelace configuration.
</li>
</ul>
<p>Things that currently do not work:</p>
<ul>
<li>
Live videostreams using the streaming integration
</li>
<li>Specifying a view with a single card with "panel: true".</li>
</ul>
</div>
<div class="section-header" id="https">
How do I change what is shown on my Chromecast?
</div>
<div class="card-content">
<p>
Home Assistant Cast allows you to show your Lovelace view on your
Chromecast. So to edit what is shown, you need to edit your Lovelace
UI.
</p>
<p>
To edit your Lovelace UI, open Home Assistant, click on the three-dot
menu in the top right and click on "Configure UI".
</p>
</div>
<div class="section-header" id="browser">
What browsers are supported?
</div>
<div class="card-content">
<p>
Chromecast is a technology developed by Google, and is available on:
</p>
<ul>
<li>Google Chrome (all platforms except on iOS)</li>
<li>
Microsoft Edge (all platforms,
<a href="https://www.microsoftedgeinsider.com" target="_blank"
>dev and canary builds only</a
>)
</li>
</ul>
</div>
<div class="section-header">Why do some custom cards not work?</div>
<div class="card-content">
<p>
Home Assistant needs to be configured to allow Home Assistant Cast to
load custom cards. Starting with Home Assistant 0.97, this is done
automatically. If you are on an older version, or have manually
configured CORS for the HTTP integration, add the following to your
configuration.yaml file:
</p>
<pre>
http:
cors_allowed_origins:
- https://cast.home-assistant.io</pre
>
<p>
Some custom cards rely on things that are only available in the normal
Home Assistant interface. This requires an update by the custom card
developer.
</p>
<p>
If you're a custom card developer: the most common mistake is that
LitElement is extracted from an element that is not available on the
page.
</p>
</div>
</hc-layout>
<script>
var _gaq = [["_setAccount", "UA-57927901-9"], ["_trackPageview"]];
(function(d, t) {
var g = d.createElement(t),
s = d.getElementsByTagName(t)[0];
g.src =
("https:" == location.protocol ? "//ssl" : "//www") +
".google-analytics.com/ga.js";
s.parentNode.insertBefore(g, s);
})(document, "script");
</script>
</body>
</html>

View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<title>Home Assistant Cast</title>
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/images/ha-cast-icon.png" type="image/png" />
<%= renderTemplate('_style_base') %>
<style>
body {
background-color: #e5e5e5;
}
</style>
<meta property="fb:app_id" content="338291289691179">
<meta property="og:title" content="Home Assistant Cast">
<meta property="og:site_name" content="Home Assistant Cast">
<meta property="og:url" content="https://cast.home-assistant.io/">
<meta property="og:type" content="website">
<meta property="og:description" content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen.">
<meta property="og:image" content="https://cast.home-assistant.io/images/google-nest-hub.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@home_assistant">
<meta name="twitter:title" content="Home Assistant Cast">
<meta name="twitter:description" content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen.">
<meta name="twitter:image" content="https://cast.home-assistant.io/images/google-nest-hub.png">
</head>
<body>
<%= renderTemplate('_js_base') %>
<hc-connect></hc-connect>
<script type="module" crossorigin="use-credentials">
import "<%= latestLauncherJS %>";
</script>
<script nomodule>
(function() {
// // Safari 10.1 supports type=module but ignores nomodule, so we add this check.
if (!isS101) {
_ls("/static/polyfills/custom-elements-es5-adapter.js");
_ls("<%= es5LauncherJS %>");
}
})();
</script>
<script>
var _gaq=[['_setAccount','UA-57927901-9'],['_trackPageview']];
(function(d,t){var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=('https:'==location.protocol?'//ssl':'//www')+'.google-analytics.com/ga.js';
s.parentNode.insertBefore(g,s)}(document,'script'));
</script>
</body>
</html>

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<script src="//www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js"></script>
<script type="module" src="<%= latestReceiverJS %>"></script>
<%= renderTemplate('_style_base') %>
<style>
body {
background-color: white;
font-size: initial;
}
</style>
</html>

View File

@ -0,0 +1,5 @@
import "../../../src/resources/ha-style";
import "../../../src/resources/roboto";
import "../../../src/components/ha-iconset-svg";
import "../../../src/resources/hass-icons";
import "./layout/hc-connect";

View File

@ -0,0 +1,290 @@
import {
customElement,
LitElement,
property,
TemplateResult,
html,
CSSResult,
css,
} from "lit-element";
import { Connection, Auth } from "home-assistant-js-websocket";
import "@polymer/iron-icon";
import "@polymer/paper-listbox/paper-listbox";
import "@polymer/paper-item/paper-icon-item";
import "../../../../src/components/ha-icon";
import {
enableWrite,
askWrite,
saveTokens,
} from "../../../../src/common/auth/token_storage";
import {
ensureConnectedCastSession,
castSendShowLovelaceView,
} from "../../../../src/cast/receiver_messages";
import "../../../../src/layouts/loading-screen";
import { CastManager } from "../../../../src/cast/cast_manager";
import {
LovelaceConfig,
getLovelaceCollection,
} from "../../../../src/data/lovelace";
import "./hc-layout";
import { generateDefaultViewConfig } from "../../../../src/panels/lovelace/common/generate-lovelace-config";
@customElement("hc-cast")
class HcCast extends LitElement {
@property() public auth!: Auth;
@property() public connection!: Connection;
@property() public castManager!: CastManager;
@property() private askWrite = false;
@property() private lovelaceConfig?: LovelaceConfig | null;
protected render(): TemplateResult | void {
if (this.lovelaceConfig === undefined) {
return html`
<loading-screen></loading-screen>>
`;
}
const error =
this.castManager.castState === "NO_DEVICES_AVAILABLE"
? html`
<p>
There were no suitable Chromecast devices to cast to found.
</p>
`
: undefined;
return html`
<hc-layout .auth=${this.auth} .connection=${this.connection}>
${this.askWrite
? html`
<p class="question action-item">
Stay logged in?
<span>
<mwc-button @click=${this._handleSaveTokens}>
YES
</mwc-button>
<mwc-button @click=${this._handleSkipSaveTokens}>
NO
</mwc-button>
</span>
</p>
`
: ""}
${error
? html`
<div class="card-content">${error}</div>
`
: !this.castManager.status
? html`
<p class="center-item">
<mwc-button raised @click=${this._handleLaunch}>
<iron-icon icon="hass:cast"></iron-icon>
Start Casting
</mwc-button>
</p>
`
: html`
<div class="section-header">PICK A VIEW</div>
<paper-listbox
attr-for-selected="data-path"
.selected=${this.castManager.status.lovelacePath || ""}
>
${(this.lovelaceConfig
? this.lovelaceConfig.views
: [generateDefaultViewConfig([], [], [], {}, () => "")]
).map(
(view, idx) => html`
<paper-icon-item
@click=${this._handlePickView}
data-path=${view.path || idx}
>
${view.icon
? html`
<ha-icon
.icon=${view.icon}
slot="item-icon"
></ha-icon>
`
: ""}
${view.title || view.path}
</paper-icon-item>
`
)}
</paper-listbox>
`}
<div class="card-actions">
${this.castManager.status
? html`
<mwc-button @click=${this._handleLaunch}>
<iron-icon icon="hass:cast-connected"></iron-icon>
Manage
</mwc-button>
`
: ""}
<div class="spacer"></div>
<mwc-button @click=${this._handleLogout}>Log out</mwc-button>
</div>
</hc-layout>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
const llColl = getLovelaceCollection(this.connection);
// We first do a single refresh because we need to check if there is LL
// configuration.
llColl.refresh().then(
() => {
llColl.subscribe((config) => {
this.lovelaceConfig = config;
});
},
async () => {
this.lovelaceConfig = null;
}
);
this.askWrite = askWrite();
this.castManager.addEventListener("state-changed", () => {
this.requestUpdate();
});
this.castManager.addEventListener("connection-changed", () => {
this.requestUpdate();
});
}
protected updated(changedProps) {
super.updated(changedProps);
if (this.castManager && this.castManager.status) {
const selectEl = this.shadowRoot!.querySelector("select");
if (selectEl) {
this.shadowRoot!.querySelector("select")!.value =
this.castManager.castConnectedToOurHass &&
!this.castManager.status.showDemo
? this.castManager.status.lovelacePath || ""
: "";
}
}
this.toggleAttribute(
"hide-icons",
this.lovelaceConfig
? !this.lovelaceConfig.views.some((view) => view.icon)
: true
);
}
private async _handleSkipSaveTokens() {
this.askWrite = false;
}
private async _handleSaveTokens() {
enableWrite();
this.askWrite = false;
}
private _handleLaunch() {
this.castManager.requestSession();
}
private async _handlePickView(ev: Event) {
const path = (ev.currentTarget as any).getAttribute("data-path");
await ensureConnectedCastSession(this.castManager!, this.auth!);
castSendShowLovelaceView(this.castManager, path);
}
private async _handleLogout() {
try {
await this.auth.revoke();
saveTokens(null);
if (this.castManager.castSession) {
this.castManager.castContext.endCurrentSession(true);
}
this.connection.close();
location.reload();
} catch (err) {
alert("Unable to log out!");
}
}
static get styles(): CSSResult {
return css`
.center-item {
display: flex;
justify-content: space-around;
}
.action-item {
display: flex;
align-items: center;
justify-content: space-between;
}
.question {
position: relative;
padding: 8px 16px;
}
.question:before {
border-radius: 4px;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
pointer-events: none;
content: "";
background-color: var(--primary-color);
opacity: 0.12;
will-change: opacity;
}
.connection,
.connection a {
color: var(--secondary-text-color);
}
mwc-button iron-icon {
margin-right: 8px;
height: 18px;
}
paper-listbox {
padding-top: 0;
}
paper-listbox ha-icon {
padding: 12px;
color: var(--secondary-text-color);
}
paper-icon-item {
cursor: pointer;
}
paper-icon-item[disabled] {
cursor: initial;
}
:host([hide-icons]) paper-icon-item {
--paper-item-icon-width: 0px;
}
.spacer {
flex: 1;
}
.card-content a {
color: var(--primary-color);
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hc-cast": HcCast;
}
}

View File

@ -0,0 +1,333 @@
import {
LitElement,
customElement,
property,
TemplateResult,
html,
CSSResult,
css,
} from "lit-element";
import {
getAuth,
createConnection,
Auth,
getAuthOptions,
ERR_HASS_HOST_REQUIRED,
ERR_INVALID_HTTPS_TO_HTTP,
Connection,
ERR_CANNOT_CONNECT,
ERR_INVALID_AUTH,
} from "home-assistant-js-websocket";
import "@polymer/iron-icon";
import "@material/mwc-button";
import "@polymer/paper-input/paper-input";
import {
loadTokens,
saveTokens,
} from "../../../../src/common/auth/token_storage";
import "../../../../src/layouts/loading-screen";
import { CastManager, getCastManager } from "../../../../src/cast/cast_manager";
import "./hc-layout";
import { castSendShowDemo } from "../../../../src/cast/receiver_messages";
import { registerServiceWorker } from "../../../../src/util/register-service-worker";
const seeFAQ = (qid) => html`
See <a href="./faq.html${qid ? `#${qid}` : ""}">the FAQ</a> for more
information.
`;
const translateErr = (err) =>
err === ERR_CANNOT_CONNECT
? "Unable to connect"
: err === ERR_HASS_HOST_REQUIRED
? "Please enter a Home Assistant URL."
: err === ERR_INVALID_HTTPS_TO_HTTP
? html`
Cannot connect to Home Assistant instances over "http://".
${seeFAQ("https")}
`
: `Unknown error (${err}).`;
const INTRO = html`
<p>
Home Assistant Cast allows you to cast your Home Assistant installation to
Chromecast video devices and to Google Assistant devices with a screen.
</p>
<p>
For more information, see the
<a href="./faq.html">frequently asked questions</a>.
</p>
`;
@customElement("hc-connect")
export class HcConnect extends LitElement {
@property() private loading = false;
// If we had stored credentials but we cannot connect,
// show a screen asking retry or logout.
@property() private cannotConnect = false;
@property() private error?: string | TemplateResult;
@property() private auth?: Auth;
@property() private connection?: Connection;
@property() private castManager?: CastManager | null;
private openDemo = false;
protected render(): TemplateResult | void {
if (this.cannotConnect) {
const tokens = loadTokens();
return html`
<hc-layout>
<div class="card-content">
Unable to connect to ${tokens!.hassUrl}.
</div>
<div class="card-actions">
<a href="/">
<mwc-button>
Retry
</mwc-button>
</a>
<div class="spacer"></div>
<mwc-button @click=${this._handleLogout}>Log out</mwc-button>
</div>
</hc-layout>
`;
}
if (this.castManager === undefined || this.loading) {
return html`
<loading-screen></loading-screen>
`;
}
if (this.castManager === null) {
return html`
<hc-layout>
<div class="card-content">
${INTRO}
<p class="error">
The Cast API is not available in your browser.
${seeFAQ("browser")}
</p>
</div>
</hc-layout>
`;
}
if (!this.auth) {
return html`
<hc-layout>
<div class="card-content">
${INTRO}
<p>
To get started, enter your Home Assistant URL and click authorize.
If you want a preview instead, click the show demo button.
</p>
<p>
<paper-input
label="Home Assistant URL"
placeholder="https://abcdefghijklmnop.ui.nabu.casa"
@keydown=${this._handleInputKeyDown}
></paper-input>
</p>
${this.error
? html`
<p class="error">${this.error}</p>
`
: ""}
</div>
<div class="card-actions">
<mwc-button @click=${this._handleDemo}>
Show Demo
<iron-icon
.icon=${this.castManager.castState === "CONNECTED"
? "hass:cast-connected"
: "hass:cast"}
></iron-icon>
</mwc-button>
<div class="spacer"></div>
<mwc-button @click=${this._handleConnect}>Authorize</mwc-button>
</div>
</hc-layout>
`;
}
return html`
<hc-cast
.connection=${this.connection}
.auth=${this.auth}
.castManager=${this.castManager}
></hc-cast>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
import("./hc-cast");
getCastManager().then(
async (mgr) => {
this.castManager = mgr;
mgr.addEventListener("connection-changed", () => {
this.requestUpdate();
});
mgr.addEventListener("state-changed", () => {
if (this.openDemo && mgr.castState === "CONNECTED" && !this.auth) {
castSendShowDemo(mgr);
}
});
if (location.search.indexOf("auth_callback=1") !== -1) {
this._tryConnection("auth-callback");
} else if (loadTokens()) {
this._tryConnection("saved-tokens");
}
},
() => {
this.castManager = null;
}
);
registerServiceWorker(false);
}
private async _handleDemo() {
this.openDemo = true;
if (this.castManager!.status && !this.castManager!.status.showDemo) {
castSendShowDemo(this.castManager!);
} else {
this.castManager!.requestSession();
}
}
private _handleInputKeyDown(ev: KeyboardEvent) {
// Handle pressing enter.
if (ev.keyCode === 13) {
this._handleConnect();
}
}
private async _handleConnect() {
const inputEl = this.shadowRoot!.querySelector("paper-input")!;
const value = inputEl.value || "";
this.error = undefined;
if (value === "") {
this.error = "Please enter a Home Assistant URL.";
return;
} else if (value.indexOf("://") === -1) {
this.error =
"Please enter your full URL, including the protocol part (https://).";
return;
}
let url: URL;
try {
url = new URL(value);
} catch (err) {
this.error = "Invalid URL";
return;
}
if (url.protocol === "http:" && url.hostname !== "localhost") {
this.error = translateErr(ERR_INVALID_HTTPS_TO_HTTP);
return;
}
await this._tryConnection("user-request", `${url.protocol}//${url.host}`);
}
private async _tryConnection(
init: "auth-callback" | "user-request" | "saved-tokens",
hassUrl?: string
) {
const options: getAuthOptions = {
saveTokens,
loadTokens: () => Promise.resolve(loadTokens()),
};
if (hassUrl) {
options.hassUrl = hassUrl;
}
let auth: Auth;
try {
this.loading = true;
auth = await getAuth(options);
} catch (err) {
if (init === "saved-tokens" && err === ERR_CANNOT_CONNECT) {
this.cannotConnect = true;
return;
}
this.error = translateErr(err);
this.loading = false;
return;
} finally {
// Clear url if we have a auth callback in url.
if (location.search.includes("auth_callback=1")) {
history.replaceState(null, "", location.pathname);
}
}
let conn: Connection;
try {
conn = await createConnection({ auth });
} catch (err) {
// In case of saved tokens, silently solve problems.
if (init === "saved-tokens") {
if (err === ERR_CANNOT_CONNECT) {
this.cannotConnect = true;
} else if (err === ERR_INVALID_AUTH) {
saveTokens(null);
}
} else {
this.error = translateErr(err);
}
return;
} finally {
this.loading = false;
}
this.auth = auth;
this.connection = conn;
this.castManager!.auth = auth;
}
private async _handleLogout() {
try {
saveTokens(null);
location.reload();
} catch (err) {
alert("Unable to log out!");
}
}
static get styles(): CSSResult {
return css`
.card-content a {
color: var(--primary-color);
}
.card-actions a {
text-decoration: none;
}
.error {
color: red;
font-weight: bold;
}
.error a {
color: darkred;
}
mwc-button iron-icon {
margin-left: 8px;
}
.spacer {
flex: 1;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hc-connect": HcConnect;
}
}

View File

@ -0,0 +1,166 @@
import {
customElement,
LitElement,
TemplateResult,
html,
CSSResult,
css,
property,
} from "lit-element";
import {
Auth,
Connection,
HassUser,
getUser,
} from "home-assistant-js-websocket";
import "../../../../src/components/ha-card";
@customElement("hc-layout")
class HcLayout extends LitElement {
@property() public subtitle?: string | undefined;
@property() public auth?: Auth;
@property() public connection?: Connection;
@property() public user?: HassUser;
protected render(): TemplateResult | void {
return html`
<ha-card>
<div class="layout">
<img class="hero" src="/images/google-nest-hub.png" />
<div class="card-header">
Home Assistant Cast${this.subtitle ? ` ${this.subtitle}` : ""}
${this.auth
? html`
<div class="subtitle">
<a href=${this.auth.data.hassUrl} target="_blank"
>${this.auth.data.hassUrl.substr(
this.auth.data.hassUrl.indexOf("//") + 2
)}</a
>
${this.user
? html`
${this.user.name}
`
: ""}
</div>
`
: ""}
</div>
<slot></slot>
</div>
</ha-card>
<div class="footer">
<a href="./faq.html">Frequently Asked Questions</a> Found a bug? Let
@balloob know
<!-- <a
href="https://github.com/home-assistant/home-assistant-polymer/issues"
target="_blank"
>Let us know!</a
> -->
</div>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
if (this.connection) {
getUser(this.connection).then((user) => {
this.user = user;
});
}
}
static get styles(): CSSResult {
return css`
:host {
display: flex;
min-height: 100%;
align-items: center;
justify-content: center;
flex-direction: column;
}
ha-card {
display: flex;
width: 100%;
max-width: 500px;
}
.layout {
display: flex;
flex-direction: column;
}
.card-header {
color: var(--ha-card-header-color, --primary-text-color);
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, 24px);
letter-spacing: -0.012em;
line-height: 32px;
padding: 24px 16px 16px;
display: block;
}
.subtitle {
font-size: 14px;
color: var(--secondary-text-color);
line-height: initial;
}
.subtitle a {
color: var(--secondary-text-color);
}
:host ::slotted(.card-content:not(:first-child)),
slot:not(:first-child)::slotted(.card-content) {
padding-top: 0px;
margin-top: -8px;
}
:host ::slotted(.section-header) {
font-weight: 500;
padding: 4px 16px;
text-transform: uppercase;
}
:host ::slotted(.card-content) {
padding: 16px;
flex: 1;
}
:host ::slotted(.card-actions) {
border-top: 1px solid #e8e8e8;
padding: 5px 16px;
display: flex;
}
img {
width: 100%;
}
.footer {
text-align: center;
font-size: 12px;
padding: 8px 0 24px;
color: var(--secondary-text-color);
}
.footer a {
color: var(--secondary-text-color);
}
@media all and (max-width: 500px) {
:host {
justify-content: flex-start;
min-height: 90%;
margin-bottom: 30px;
}
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hc-layout": HcLayout;
}
}

View File

@ -0,0 +1 @@
export const castContext = cast.framework.CastReceiverContext.getInstance();

View File

@ -0,0 +1,141 @@
import { Entity, convertEntities } from "../../../../src/fake_data/entity";
export const castDemoEntities: () => Entity[] = () =>
convertEntities({
"light.reading_light": {
entity_id: "light.reading_light",
state: "on",
attributes: {
friendly_name: "Reading Light",
},
},
"light.ceiling": {
entity_id: "light.ceiling",
state: "on",
attributes: {
friendly_name: "Ceiling lights",
},
},
"light.standing_lamp": {
entity_id: "light.standing_lamp",
state: "off",
attributes: {
friendly_name: "Standing Lamp",
},
},
"sensor.temperature_inside": {
entity_id: "sensor.temperature_inside",
state: "22.7",
attributes: {
battery_level: 78,
unit_of_measurement: "\u00b0C",
friendly_name: "Inside",
device_class: "temperature",
},
},
"sensor.temperature_outside": {
entity_id: "sensor.temperature_outside",
state: "31.4",
attributes: {
battery_level: 53,
unit_of_measurement: "\u00b0C",
friendly_name: "Outside",
device_class: "temperature",
},
},
"person.arsaboo": {
entity_id: "person.arsaboo",
state: "not_home",
attributes: {
radius: 50,
friendly_name: "Arsaboo",
latitude: 52.3579946,
longitude: 4.8664597,
entity_picture: "/images/arsaboo.jpg",
},
},
"person.melody": {
entity_id: "person.melody",
state: "not_home",
attributes: {
radius: 50,
friendly_name: "Melody",
latitude: 52.3408927,
longitude: 4.8711073,
entity_picture: "/images/melody.jpg",
},
},
"zone.home": {
entity_id: "zone.home",
state: "zoning",
attributes: {
hidden: true,
latitude: 52.3631339,
longitude: 4.8903147,
radius: 100,
friendly_name: "Home",
icon: "hass:home",
},
},
"input_number.harmonyvolume": {
entity_id: "input_number.harmonyvolume",
state: "18.0",
attributes: {
initial: 30,
min: 1,
max: 100,
step: 1,
mode: "slider",
friendly_name: "Volume",
icon: "hass:volume-high",
},
},
"climate.upstairs": {
entity_id: "climate.upstairs",
state: "auto",
attributes: {
current_temperature: 24,
min_temp: 15,
max_temp: 30,
temperature: null,
target_temp_high: 26,
target_temp_low: 18,
fan_mode: "auto",
fan_modes: ["auto", "on"],
hvac_modes: ["auto", "cool", "heat", "off"],
aux_heat: "off",
actual_humidity: 30,
fan: "on",
operation: "fan",
fan_min_on_time: 10,
friendly_name: "Upstairs",
supported_features: 27,
preset_mode: "away",
preset_modes: ["home", "away", "eco", "sleep"],
},
},
"climate.downstairs": {
entity_id: "climate.downstairs",
state: "auto",
attributes: {
current_temperature: 22,
min_temp: 15,
max_temp: 30,
temperature: null,
target_temp_high: 24,
target_temp_low: 20,
fan_mode: "auto",
fan_modes: ["auto", "on"],
hvac_modes: ["auto", "cool", "heat", "off"],
aux_heat: "off",
actual_humidity: 30,
fan: "on",
operation: "fan",
fan_min_on_time: 10,
friendly_name: "Downstairs",
supported_features: 27,
preset_mode: "home",
preset_modes: ["home", "away", "eco", "sleep"],
},
},
});

View File

@ -0,0 +1,93 @@
import {
LovelaceConfig,
LovelaceCardConfig,
} from "../../../../src/data/lovelace";
import { castContext } from "../cast_context";
export const castDemoLovelace: () => LovelaceConfig = () => {
const touchSupported = castContext.getDeviceCapabilities()
.touch_input_supported;
return {
views: [
{
path: "overview",
cards: [
{
type: "markdown",
title: "Home Assistant Cast",
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Assistant devices with a screen.${
touchSupported
? "\n\nYou are able to interact with this demo using the touch screen."
: "\n\nOn a Google Nest Hub you are able to interact with Home Assistant Cast via the touch screen."
}`,
},
{
type: touchSupported ? "entities" : "glance",
title: "Living Room",
entities: [
"light.reading_light",
"light.ceiling",
"light.standing_lamp",
"input_number.harmonyvolume",
],
},
{
cards: [
{
graph: "line",
type: "sensor",
entity: "sensor.temperature_inside",
},
{
graph: "line",
type: "sensor",
entity: "sensor.temperature_outside",
},
],
type: "horizontal-stack",
},
{
type: "map",
entities: ["person.arsaboo", "person.melody", "zone.home"],
aspect_ratio: touchSupported ? "16:9.3" : "16:11",
},
touchSupported && {
type: "entities",
entities: [
{
type: "weblink",
url: "/lovelace/climate",
name: "Climate controls",
icon: "hass:arrow-right",
},
],
},
].filter(Boolean) as LovelaceCardConfig[],
},
{
path: "climate",
cards: [
{
type: "thermostat",
entity: "climate.downstairs",
},
{
type: "entities",
entities: [
{
type: "weblink",
url: "/lovelace/overview",
name: "Back",
icon: "hass:arrow-left",
},
],
},
{
type: "thermostat",
entity: "climate.upstairs",
},
],
},
],
};
};

View File

@ -0,0 +1,42 @@
import "../../../src/resources/custom-card-support";
import { castContext } from "./cast_context";
import { ReceivedMessage } from "./types";
import { HassMessage } from "../../../src/cast/receiver_messages";
import { HcMain } from "./layout/hc-main";
import { CAST_NS } from "../../../src/cast/const";
const controller = new HcMain();
document.body.append(controller);
const options = new cast.framework.CastReceiverOptions();
options.disableIdleTimeout = true;
options.customNamespaces = {
// @ts-ignore
[CAST_NS]: cast.framework.system.MessageType.JSON,
};
// The docs say we need to set options.touchScreenOptimizeApp = true
// https://developers.google.com/cast/docs/caf_receiver/customize_ui#accessing_ui_controls
// This doesn't work.
// @ts-ignore
options.touchScreenOptimizedApp = true;
// The class reference say we can set a uiConfig in options to set it
// https://developers.google.com/cast/docs/reference/caf_receiver/cast.framework.CastReceiverOptions#uiConfig
// This doesn't work either.
// @ts-ignore
options.uiConfig = new cast.framework.ui.UiConfig();
// @ts-ignore
options.uiConfig.touchScreenOptimizedApp = true;
castContext.addCustomMessageListener(
CAST_NS,
// @ts-ignore
(ev: ReceivedMessage<HassMessage>) => {
const msg = ev.data;
msg.senderId = ev.senderId;
controller.processIncomingMessage(msg);
}
);
castContext.start(options);

View File

@ -0,0 +1,56 @@
import { HassElement } from "../../../../src/state/hass-element";
import "./hc-lovelace";
import { customElement, TemplateResult, html, property } from "lit-element";
import {
MockHomeAssistant,
provideHass,
} from "../../../../src/fake_data/provide_hass";
import { HomeAssistant } from "../../../../src/types";
import { LovelaceConfig } from "../../../../src/data/lovelace";
import { castDemoEntities } from "../demo/cast-demo-entities";
import { castDemoLovelace } from "../demo/cast-demo-lovelace";
import { mockHistory } from "../../../../demo/src/stubs/history";
@customElement("hc-demo")
class HcDemo extends HassElement {
@property() public lovelacePath!: string;
@property() private _lovelaceConfig?: LovelaceConfig;
protected render(): TemplateResult | void {
if (!this._lovelaceConfig) {
return html``;
}
return html`
<hc-lovelace
.hass=${this.hass}
.lovelaceConfig=${this._lovelaceConfig}
.viewPath=${this.lovelacePath}
></hc-lovelace>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this._initialize();
}
private async _initialize() {
const initial: Partial<MockHomeAssistant> = {
// Override updateHass so that the correct hass lifecycle methods are called
updateHass: (hassUpdate: Partial<HomeAssistant>) =>
this._updateHass(hassUpdate),
};
const hass = (this.hass = provideHass(this, initial));
mockHistory(hass);
hass.addEntities(castDemoEntities());
this._lovelaceConfig = castDemoLovelace();
}
}
declare global {
interface HTMLElementTagNameMap {
"hc-demo": HcDemo;
}
}

View File

@ -0,0 +1,66 @@
import {
LitElement,
TemplateResult,
html,
customElement,
CSSResult,
css,
property,
} from "lit-element";
import { HomeAssistant } from "../../../../src/types";
@customElement("hc-launch-screen")
class HcLaunchScreen extends LitElement {
@property() public hass?: HomeAssistant;
@property() public error?: string;
protected render(): TemplateResult | void {
return html`
<div class="container">
<img
src="https://www.home-assistant.io/images/blog/2018-09-thinking-big/social.png"
/>
<div class="status">
${this.hass ? "Connected" : "Not Connected"}
${this.error
? html`
<p>Error: ${this.error}</p>
`
: ""}
</div>
</div>
`;
}
static get styles(): CSSResult {
return css`
:host {
display: block;
height: 100vh;
padding-top: 64px;
background-color: white;
font-size: 24px;
}
.container {
display: flex;
flex-direction: column;
text-align: center;
}
img {
width: 717px;
height: 376px;
display: block;
margin: 0 auto;
}
.status {
padding-right: 54px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hc-launch-screen": HcLaunchScreen;
}
}

View File

@ -0,0 +1,100 @@
import {
LitElement,
TemplateResult,
html,
customElement,
CSSResult,
css,
property,
} from "lit-element";
import { LovelaceConfig } from "../../../../src/data/lovelace";
import "../../../../src/panels/lovelace/hui-view";
import { HomeAssistant } from "../../../../src/types";
import { Lovelace } from "../../../../src/panels/lovelace/types";
import "./hc-launch-screen";
@customElement("hc-lovelace")
class HcLovelace extends LitElement {
@property() public hass!: HomeAssistant;
@property() public lovelaceConfig!: LovelaceConfig;
@property() public viewPath?: string;
protected render(): TemplateResult | void {
const index = this._viewIndex;
if (index === undefined) {
return html`
<hc-launch-screen
.hass=${this.hass}
.error=${`Unable to find a view with path ${this.viewPath}`}
></hc-launch-screen>
`;
}
const lovelace: Lovelace = {
config: this.lovelaceConfig,
editMode: false,
enableFullEditMode: () => undefined,
mode: "storage",
language: "en",
saveConfig: async () => undefined,
setEditMode: () => undefined,
};
return html`
<hui-view
.hass=${this.hass}
.lovelace=${lovelace}
.index=${index}
columns="2"
></hui-view>
`;
}
protected updated(changedProps) {
super.updated(changedProps);
if (changedProps.has("viewPath") || changedProps.has("lovelaceConfig")) {
const index = this._viewIndex;
if (index) {
this.shadowRoot!.querySelector("hui-view")!.style.background =
this.lovelaceConfig.views[index].background ||
this.lovelaceConfig.background ||
"";
}
}
}
private get _viewIndex() {
const selectedView = this.viewPath;
const selectedViewInt = parseInt(selectedView!, 10);
for (let i = 0; i < this.lovelaceConfig.views.length; i++) {
if (
this.lovelaceConfig.views[i].path === selectedView ||
i === selectedViewInt
) {
return i;
}
}
return undefined;
}
static get styles(): CSSResult {
// We're applying a 10% transform so it all shows a little bigger.
return css`
:host {
min-height: 100vh;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: var(--primary-background-color);
}
hui-view {
flex: 1;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hc-lovelace": HcLovelace;
}
}

View File

@ -0,0 +1,217 @@
import { HassElement } from "../../../../src/state/hass-element";
import {
getAuth,
createConnection,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import { customElement, TemplateResult, html, property } from "lit-element";
import {
HassMessage,
ConnectMessage,
ShowLovelaceViewMessage,
GetStatusMessage,
ShowDemoMessage,
} from "../../../../src/cast/receiver_messages";
import {
LovelaceConfig,
getLovelaceCollection,
} from "../../../../src/data/lovelace";
import "./hc-launch-screen";
import { castContext } from "../cast_context";
import { CAST_NS } from "../../../../src/cast/const";
import { ReceiverStatusMessage } from "../../../../src/cast/sender_messages";
import { loadLovelaceResources } from "../../../../src/panels/lovelace/common/load-resources";
import { isNavigationClick } from "../../../../src/common/dom/is-navigation-click";
@customElement("hc-main")
export class HcMain extends HassElement {
@property() private _showDemo = false;
@property() private _lovelaceConfig?: LovelaceConfig;
@property() private _lovelacePath: string | null = null;
@property() private _error?: string;
private _unsubLovelace?: UnsubscribeFunc;
public processIncomingMessage(msg: HassMessage) {
if (msg.type === "connect") {
this._handleConnectMessage(msg);
} else if (msg.type === "show_lovelace_view") {
this._handleShowLovelaceMessage(msg);
} else if (msg.type === "get_status") {
this._handleGetStatusMessage(msg);
} else if (msg.type === "show_demo") {
this._handleShowDemo(msg);
} else {
// tslint:disable-next-line: no-console
console.warn("unknown msg type", msg);
}
}
protected render(): TemplateResult | void {
if (this._showDemo) {
return html`
<hc-demo .lovelacePath=${this._lovelacePath}></hc-demo>
`;
}
if (!this._lovelaceConfig || !this._lovelacePath) {
return html`
<hc-launch-screen
.hass=${this.hass}
.error=${this._error}
></hc-launch-screen>
`;
}
return html`
<hc-lovelace
.hass=${this.hass}
.lovelaceConfig=${this._lovelaceConfig}
.viewPath=${this._lovelacePath}
></hc-lovelace>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
import("../second-load");
window.addEventListener("location-changed", () => {
if (location.pathname.startsWith("/lovelace/")) {
this._lovelacePath = location.pathname.substr(10);
this._sendStatus();
}
});
document.body.addEventListener("click", (ev) => {
const href = isNavigationClick(ev);
if (href && href.startsWith("/lovelace/")) {
this._lovelacePath = href.substr(10);
this._sendStatus();
}
});
}
private _sendStatus(senderId?: string) {
const status: ReceiverStatusMessage = {
type: "receiver_status",
connected: !!this.hass,
showDemo: this._showDemo,
};
if (this.hass) {
status.hassUrl = this.hass.auth.data.hassUrl;
status.lovelacePath = this._lovelacePath!;
}
if (senderId) {
this.sendMessage(senderId, status);
} else {
for (const sender of castContext.getSenders()) {
this.sendMessage(sender.id, status);
}
}
}
private async _handleGetStatusMessage(msg: GetStatusMessage) {
this._sendStatus(msg.senderId!);
}
private async _handleConnectMessage(msg: ConnectMessage) {
let auth;
try {
auth = await getAuth({
loadTokens: async () => ({
hassUrl: msg.hassUrl,
clientId: msg.clientId,
refresh_token: msg.refreshToken,
access_token: "",
expires: 0,
expires_in: 0,
}),
});
} catch (err) {
this._error = err;
return;
}
const connection = await createConnection({ auth });
if (this.hass) {
this.hass.connection.close();
}
this.initializeHass(auth, connection);
this._error = undefined;
this._sendStatus();
}
private async _handleShowLovelaceMessage(msg: ShowLovelaceViewMessage) {
// We should not get this command before we are connected.
// Means a client got out of sync. Let's send status to them.
if (!this.hass) {
this._sendStatus(msg.senderId!);
this._error = "Cannot show Lovelace because we're not connected.";
return;
}
if (!this._unsubLovelace) {
const llColl = getLovelaceCollection(this.hass!.connection);
// We first do a single refresh because we need to check if there is LL
// configuration.
try {
await llColl.refresh();
this._unsubLovelace = llColl.subscribe((lovelaceConfig) =>
this._handleNewLovelaceConfig(lovelaceConfig)
);
} catch (err) {
// Generate a Lovelace config.
this._unsubLovelace = () => undefined;
const {
generateLovelaceConfigFromHass,
} = await import("../../../../src/panels/lovelace/common/generate-lovelace-config");
this._handleNewLovelaceConfig(
await generateLovelaceConfigFromHass(this.hass!)
);
}
}
this._showDemo = false;
this._lovelacePath = msg.viewPath;
if (castContext.getDeviceCapabilities().touch_input_supported) {
this._breakFree();
}
this._sendStatus();
}
private _handleNewLovelaceConfig(lovelaceConfig: LovelaceConfig) {
castContext.setApplicationState(lovelaceConfig.title!);
this._lovelaceConfig = lovelaceConfig;
if (lovelaceConfig.resources) {
loadLovelaceResources(
lovelaceConfig.resources,
this.hass!.auth.data.hassUrl
);
}
}
private _handleShowDemo(_msg: ShowDemoMessage) {
import("./hc-demo").then(() => {
this._showDemo = true;
this._lovelacePath = "overview";
this._sendStatus();
if (castContext.getDeviceCapabilities().touch_input_supported) {
this._breakFree();
}
});
}
private _breakFree() {
const controls = document.body.querySelector("touch-controls");
if (controls) {
controls.remove();
}
document.body.setAttribute("style", "overflow-y: auto !important");
}
private sendMessage(senderId: string, response: any) {
castContext.sendCustomMessage(CAST_NS, senderId, response);
}
}
declare global {
interface HTMLElementTagNameMap {
"hc-main": HcMain;
}
}

View File

@ -0,0 +1,5 @@
import "web-animations-js/web-animations-next-lite.min";
import "../../../src/resources/hass-icons";
import "../../../src/resources/roboto";
import "../../../src/components/ha-iconset-svg";
import "./layout/hc-lovelace";

View File

@ -0,0 +1,6 @@
export interface ReceivedMessage<T> {
gj: boolean;
data: T;
senderId: string;
type: "message";
}

View File

@ -23,6 +23,9 @@ export const demoLovelaceArsaboo: DemoConfig["lovelace"] = (localize) => ({
entity: "switch.wemoporch", entity: "switch.wemoporch",
}, },
"light.lifx5", "light.lifx5",
{
type: "custom:cast-demo-row",
},
], ],
}, },
{ {

View File

@ -0,0 +1,108 @@
import {
html,
LitElement,
TemplateResult,
customElement,
property,
css,
CSSResult,
} from "lit-element";
import "../../../src/components/ha-icon";
import {
EntityRow,
CastConfig,
} from "../../../src/panels/lovelace/entity-rows/types";
import { HomeAssistant } from "../../../src/types";
import { CastManager } from "../../../src/cast/cast_manager";
import { castSendShowDemo } from "../../../src/cast/receiver_messages";
@customElement("cast-demo-row")
class CastDemoRow extends LitElement implements EntityRow {
public hass!: HomeAssistant;
@property() private _castManager?: CastManager | null;
public setConfig(_config: CastConfig): void {
// No config possible.
}
protected render(): TemplateResult | void {
if (
!this._castManager ||
this._castManager.castState === "NO_DEVICES_AVAILABLE"
) {
return html``;
}
return html`
<ha-icon icon="hademo:television"></ha-icon>
<div class="flex">
<div class="name">Show Chromecast interface</div>
<google-cast-launcher></google-cast-launcher>
</div>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
import("../../../src/cast/cast_manager").then(({ getCastManager }) =>
getCastManager().then((mgr) => {
this._castManager = mgr;
mgr.addEventListener("state-changed", () => {
this.requestUpdate();
});
mgr.castContext.addEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
(ev) => {
if (ev.sessionState === "SESSION_STARTED") {
castSendShowDemo(mgr);
}
}
);
})
);
}
protected updated(changedProps) {
super.updated(changedProps);
this.style.display = this._castManager ? "" : "none";
}
static get styles(): CSSResult {
return css`
:host {
display: flex;
align-items: center;
}
ha-icon {
padding: 8px;
color: var(--paper-item-icon-color);
}
.flex {
flex: 1;
overflow: hidden;
margin-left: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
google-cast-launcher {
cursor: pointer;
display: inline-block;
height: 24px;
width: 24px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"cast-demo-row": CastDemoRow;
}
}

View File

@ -1,4 +1,5 @@
import "../custom-cards/ha-demo-card"; import "../custom-cards/ha-demo-card";
import "../custom-cards/cast-demo-row";
// Not duplicate, one is for typing. // Not duplicate, one is for typing.
// tslint:disable-next-line // tslint:disable-next-line
import { HADemoCard } from "../custom-cards/ha-demo-card"; import { HADemoCard } from "../custom-cards/ha-demo-card";

View File

@ -8,7 +8,7 @@
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"build": "script/build_frontend", "build": "script/build_frontend",
"lint": "eslint src hassio/src gallery/src && tslint 'src/**/*.ts' 'hassio/src/**/*.ts' 'gallery/src/**/*.ts' 'test-mocha/**/*.ts' && polymer lint && tsc", "lint": "eslint src hassio/src gallery/src && tslint 'src/**/*.ts' 'hassio/src/**/*.ts' 'gallery/src/**/*.ts' 'cast/src/**/*.ts' 'test-mocha/**/*.ts' && polymer lint && tsc",
"mocha": "node_modules/.bin/ts-mocha -p test-mocha/tsconfig.test.json --opts test-mocha/mocha.opts", "mocha": "node_modules/.bin/ts-mocha -p test-mocha/tsconfig.test.json --opts test-mocha/mocha.opts",
"test": "npm run lint && npm run mocha", "test": "npm run lint && npm run mocha",
"docker_build": "sh ./script/docker_run.sh build $npm_package_version", "docker_build": "sh ./script/docker_run.sh build $npm_package_version",

View File

@ -0,0 +1,24 @@
import { loadJS } from "../common/dom/load_resource";
let loadedPromise: Promise<boolean> | undefined;
export const castApiAvailable = () => {
if (loadedPromise) {
return loadedPromise;
}
loadedPromise = new Promise((resolve) => {
(window as any).__onGCastApiAvailable = resolve;
});
// Any element with a specific ID will get set as a JS variable on window
// This will override the cast SDK if the iconset is loaded afterwards.
// Conflicting IDs will no longer mess with window, so we'll just append one.
const el = document.createElement("div");
el.id = "cast";
document.body.append(el);
loadJS(
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
);
return loadedPromise;
};

167
src/cast/cast_manager.ts Normal file
View File

@ -0,0 +1,167 @@
import { castApiAvailable } from "./cast_framework";
import { CAST_APP_ID, CAST_NS, CAST_DEV_HASS_URL, CAST_DEV } from "./const";
import {
castSendAuth,
HassMessage as ReceiverMessage,
} from "./receiver_messages";
import {
SessionStateEventData,
CastStateEventData,
// tslint:disable-next-line: no-implicit-dependencies
} from "chromecast-caf-receiver/cast.framework";
import { SenderMessage, ReceiverStatusMessage } from "./sender_messages";
import { Auth } from "home-assistant-js-websocket";
let managerProm: Promise<CastManager> | undefined;
type CastEventListener = () => void;
/*
General flow of Chromecast:
Chromecast sessions are started via the Chromecast button. When clicked, session
state changes to started. We then send authentication, which will cause the
receiver app to send a status update.
If a session is already active, we query the status to see what it is up to. If
a user presses the cast button we send auth if not connected yet, then send
command as usual.
*/
/* tslint:disable:no-console */
type CastEvent = "connection-changed" | "state-changed";
export class CastManager {
public auth?: Auth;
// If the cast connection is connected to our Hass.
public status?: ReceiverStatusMessage;
private _eventListeners: { [event: string]: CastEventListener[] } = {};
constructor(auth?: Auth) {
this.auth = auth;
const context = this.castContext;
context.setOptions({
receiverApplicationId: CAST_APP_ID,
// @ts-ignore
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
});
context.addEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
(ev) => this._sessionStateChanged(ev)
);
context.addEventListener(
cast.framework.CastContextEventType.CAST_STATE_CHANGED,
(ev) => this._castStateChanged(ev)
);
}
public addEventListener(event: CastEvent, listener: CastEventListener) {
if (!(event in this._eventListeners)) {
this._eventListeners[event] = [];
}
this._eventListeners[event].push(listener);
return () => {
this._eventListeners[event].splice(
this._eventListeners[event].indexOf(listener)
);
};
}
public get castConnectedToOurHass(): boolean {
return (
this.status !== undefined &&
this.auth !== undefined &&
this.status.connected &&
(this.status.hassUrl === this.auth.data.hassUrl ||
(CAST_DEV && this.status.hassUrl === CAST_DEV_HASS_URL))
);
}
public sendMessage(msg: ReceiverMessage) {
if (__DEV__) {
console.log("Sending cast message", msg);
}
this.castSession.sendMessage(CAST_NS, msg);
}
public get castState() {
return this.castContext.getCastState();
}
public get castContext() {
return cast.framework.CastContext.getInstance();
}
public get castSession() {
return this.castContext.getCurrentSession()!;
}
public requestSession() {
return this.castContext.requestSession();
}
private _fireEvent(event: CastEvent) {
for (const listener of this._eventListeners[event] || []) {
listener();
}
}
private _receiveMessage(msg: SenderMessage) {
if (__DEV__) {
console.log("Received cast message", msg);
}
if (msg.type === "receiver_status") {
this.status = msg;
this._fireEvent("connection-changed");
}
}
private _sessionStateChanged(ev: SessionStateEventData) {
if (__DEV__) {
console.log("Cast session state changed", ev.sessionState);
}
if (ev.sessionState === "SESSION_RESUMED") {
this.sendMessage({ type: "get_status" });
this._attachMessageListener();
} else if (ev.sessionState === "SESSION_STARTED") {
if (this.auth) {
castSendAuth(this, this.auth);
} else {
// Only do if no auth, as this is done as part of sendAuth.
this.sendMessage({ type: "get_status" });
}
this._attachMessageListener();
} else if (ev.sessionState === "SESSION_ENDED") {
this.status = undefined;
this._fireEvent("connection-changed");
}
}
private _castStateChanged(ev: CastStateEventData) {
if (__DEV__) {
console.log("Cast state changed", ev.castState);
}
this._fireEvent("state-changed");
}
private _attachMessageListener() {
const session = this.castSession;
session.addMessageListener(CAST_NS, (_ns, msg) =>
this._receiveMessage(JSON.parse(msg))
);
}
}
export const getCastManager = (auth?: Auth) => {
if (!managerProm) {
managerProm = castApiAvailable().then((isAvailable) => {
if (!isAvailable) {
throw new Error("No Cast API available");
}
return new CastManager(auth);
});
}
return managerProm;
};

11
src/cast/const.ts Normal file
View File

@ -0,0 +1,11 @@
// Guard dev mode with `__dev__` so it can only ever be enabled in dev mode.
export const CAST_DEV = __DEV__ && true;
// Replace this with your own unpublished cast app that points at your local dev
const CAST_DEV_APP_ID = "5FE44367";
export const CAST_APP_ID = CAST_DEV ? CAST_DEV_APP_ID : "B12CE3CA";
export const CAST_NS = "urn:x-cast:com.nabucasa.hast";
// Chromecast SDK will only load on localhost and HTTPS
// So during local development we have to send our dev IP address,
// but then run the UI on localhost.
export const CAST_DEV_HASS_URL = "http://192.168.1.234:8123";

View File

@ -0,0 +1,73 @@
// Nessages to be processed inside the Cast Receiver app
import { CastManager } from "./cast_manager";
import { BaseCastMessage } from "./types";
import { CAST_DEV_HASS_URL, CAST_DEV } from "./const";
import { Auth } from "home-assistant-js-websocket";
export interface GetStatusMessage extends BaseCastMessage {
type: "get_status";
}
export interface ConnectMessage extends BaseCastMessage {
type: "connect";
refreshToken: string;
clientId: string;
hassUrl: string;
}
export interface ShowLovelaceViewMessage extends BaseCastMessage {
type: "show_lovelace_view";
viewPath: string | null;
}
export interface ShowDemoMessage extends BaseCastMessage {
type: "show_demo";
}
export type HassMessage =
| ShowDemoMessage
| GetStatusMessage
| ConnectMessage
| ShowLovelaceViewMessage;
export const castSendAuth = (cast: CastManager, auth: Auth) =>
cast.sendMessage({
type: "connect",
refreshToken: auth.data.refresh_token,
clientId: auth.data.clientId,
hassUrl: CAST_DEV ? CAST_DEV_HASS_URL : auth.data.hassUrl,
});
export const castSendShowLovelaceView = (
cast: CastManager,
viewPath: ShowLovelaceViewMessage["viewPath"]
) =>
cast.sendMessage({
type: "show_lovelace_view",
viewPath,
});
export const castSendShowDemo = (cast: CastManager) =>
cast.sendMessage({
type: "show_demo",
});
export const ensureConnectedCastSession = (cast: CastManager, auth: Auth) => {
if (cast.castConnectedToOurHass) {
return;
}
return new Promise((resolve) => {
const unsub = cast.addEventListener("connection-changed", () => {
if (cast.castConnectedToOurHass) {
unsub();
resolve();
return;
}
});
castSendAuth(cast, auth);
});
};

View File

@ -0,0 +1,13 @@
import { BaseCastMessage } from "./types";
// Messages to be processed inside the Home Assistant UI
export interface ReceiverStatusMessage extends BaseCastMessage {
type: "receiver_status";
connected: boolean;
showDemo: boolean;
hassUrl?: string;
lovelacePath?: string | null;
}
export type SenderMessage = ReceiverStatusMessage;

4
src/cast/types.ts Normal file
View File

@ -0,0 +1,4 @@
export interface BaseCastMessage {
type: string;
senderId?: string;
}

View File

@ -26,6 +26,7 @@ import "../special-rows/hui-call-service-row";
import "../special-rows/hui-divider-row"; import "../special-rows/hui-divider-row";
import "../special-rows/hui-section-row"; import "../special-rows/hui-section-row";
import "../special-rows/hui-weblink-row"; import "../special-rows/hui-weblink-row";
import "../special-rows/hui-cast-row";
import { EntityConfig, EntityRow } from "../entity-rows/types"; import { EntityConfig, EntityRow } from "../entity-rows/types";
const CUSTOM_TYPE_PREFIX = "custom:"; const CUSTOM_TYPE_PREFIX = "custom:";
@ -34,6 +35,7 @@ const SPECIAL_TYPES = new Set([
"divider", "divider",
"section", "section",
"weblink", "weblink",
"cast",
]); ]);
const DOMAIN_TO_ELEMENT_TYPE = { const DOMAIN_TO_ELEMENT_TYPE = {
alert: "toggle", alert: "toggle",

View File

@ -26,12 +26,21 @@ export interface CallServiceConfig extends EntityConfig {
service: string; service: string;
service_data?: { [key: string]: any }; service_data?: { [key: string]: any };
} }
export interface CastConfig {
type: "cast";
icon: string;
name: string;
view: string;
// Hide the row if either unsupported browser or no API available.
hide_if_unavailable: boolean;
}
export type EntityRowConfig = export type EntityRowConfig =
| EntityConfig | EntityConfig
| DividerConfig | DividerConfig
| SectionConfig | SectionConfig
| WeblinkConfig | WeblinkConfig
| CallServiceConfig; | CallServiceConfig
| CastConfig;
export interface EntityRow extends HTMLElement { export interface EntityRow extends HTMLElement {
hass?: HomeAssistant; hass?: HomeAssistant;

View File

@ -0,0 +1,160 @@
import {
html,
LitElement,
TemplateResult,
customElement,
property,
css,
CSSResult,
} from "lit-element";
import { EntityRow, CastConfig } from "../entity-rows/types";
import { HomeAssistant } from "../../../types";
import "../../../components/ha-icon";
import { CastManager } from "../../../cast/cast_manager";
import {
ensureConnectedCastSession,
castSendShowLovelaceView,
} from "../../../cast/receiver_messages";
@customElement("hui-cast-row")
class HuiCastRow extends LitElement implements EntityRow {
public hass!: HomeAssistant;
@property() private _config?: CastConfig;
@property() private _castManager?: CastManager | null;
@property() private _noHTTPS = false;
public setConfig(config: CastConfig): void {
if (!config || !config.view) {
throw new Error("Invalid Configuration: 'view' required");
}
this._config = {
icon: "hass:television",
name: "Home Assistant Cast",
...config,
};
}
protected render(): TemplateResult | void {
if (!this._config) {
return html``;
}
return html`
<ha-icon .icon="${this._config.icon}"></ha-icon>
<div class="flex">
<div class="name">${this._config.name}</div>
${this._noHTTPS
? html`
Cast requires HTTPS
`
: this._castManager === undefined
? html``
: this._castManager === null
? html`
Cast API unavailable
`
: this._castManager.castState === "NO_DEVICES_AVAILABLE"
? html`
No devices found
`
: html`
<div class="controls">
<google-cast-launcher></google-cast-launcher>
<mwc-button
@click=${this._sendLovelace}
.disabled=${!this._castManager.status}
>
SHOW
</mwc-button>
</div>
`}
</div>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
if (location.protocol === "http:" && location.hostname !== "localhost") {
this._noHTTPS = true;
}
import("../../../cast/cast_manager").then(({ getCastManager }) =>
getCastManager(this.hass.auth).then(
(mgr) => {
this._castManager = mgr;
mgr.addEventListener("connection-changed", () => {
this.requestUpdate();
});
mgr.addEventListener("state-changed", () => {
this.requestUpdate();
});
},
() => {
this._castManager = null;
}
)
);
}
protected updated(changedProps) {
super.updated(changedProps);
if (this._config && this._config.hide_if_unavailable) {
this.style.display =
!this._castManager ||
this._castManager.castState === "NO_DEVICES_AVAILABLE"
? "none"
: "";
}
}
private async _sendLovelace() {
await ensureConnectedCastSession(this._castManager!, this.hass.auth);
castSendShowLovelaceView(this._castManager!, this._config!.view);
}
static get styles(): CSSResult {
return css`
:host {
display: flex;
align-items: center;
}
ha-icon {
padding: 8px;
color: var(--paper-item-icon-color);
}
.flex {
flex: 1;
overflow: hidden;
margin-left: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.controls {
margin-right: -0.57em;
display: flex;
align-items: center;
}
google-cast-launcher {
cursor: pointer;
display: inline-block;
height: 24px;
width: 24px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-cast-row": HuiCastRow;
}
}