Added Wyze
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
import voluptuous as vol
|
||||
from aiohttp import web
|
||||
from aiohttp.web_exceptions import HTTPUnauthorized, HTTPGone, HTTPNotFound
|
||||
from homeassistant.components.binary_sensor import HomeAssistant # fix tests
|
||||
from homeassistant.components.camera import async_get_stream_source, async_get_image
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_URL, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.network import get_url
|
||||
from homeassistant.helpers.template import Template
|
||||
|
||||
from . import utils
|
||||
from .utils import DOMAIN, Server
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CREATE_LINK_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("link_id"): cv.string,
|
||||
vol.Exclusive("url", "url"): cv.string,
|
||||
vol.Exclusive("entity", "url"): cv.entity_id,
|
||||
vol.Optional("open_limit", default=1): cv.positive_int,
|
||||
vol.Optional("time_to_live", default=60): cv.positive_int,
|
||||
},
|
||||
required=True,
|
||||
)
|
||||
|
||||
DASH_CAST_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Exclusive("url", "url"): cv.string,
|
||||
vol.Exclusive("entity", "url"): cv.entity_id,
|
||||
vol.Optional("extra"): dict,
|
||||
vol.Optional("force", default=False): bool,
|
||||
vol.Optional("hass_url"): str,
|
||||
},
|
||||
required=True,
|
||||
)
|
||||
|
||||
LINKS = {} # 2 3 4
|
||||
|
||||
# DDoS protection against requests to HLS proxy
|
||||
# streams are additionally protected by a random playlist identifier
|
||||
HLS_COOKIE = "webrtc-hls-session"
|
||||
HLS_SESSION = str(uuid.uuid4())
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict):
|
||||
# 1. Serve lovelace card
|
||||
path = Path(__file__).parent / "www"
|
||||
for name in ("video-rtc.js", "webrtc-camera.js", "digital-ptz.js"):
|
||||
await utils.register_static_path(hass, "/webrtc/" + name, str(path / name))
|
||||
|
||||
# 2. Add card to resources
|
||||
version = getattr(hass.data["integrations"][DOMAIN], "version", 0)
|
||||
await utils.init_resource(hass, "/webrtc/webrtc-camera.js", str(version))
|
||||
|
||||
# 3. Serve html page
|
||||
await utils.register_static_path(hass, "/webrtc/embed", str(path / "embed.html"))
|
||||
|
||||
# 4. Serve WebSocket API
|
||||
hass.http.register_view(WebSocketView)
|
||||
|
||||
# 5. Serve HLS proxy
|
||||
hass.http.register_view(HLSView)
|
||||
|
||||
# 6. Register webrtc.create_link and webrtc.dash_cast services:
|
||||
|
||||
async def create_link(call: ServiceCall):
|
||||
link_id = call.data["link_id"]
|
||||
ttl = call.data["time_to_live"]
|
||||
LINKS[link_id] = {
|
||||
"url": call.data.get("url"),
|
||||
"entity": call.data.get("entity"),
|
||||
"limit": call.data["open_limit"],
|
||||
"ts": time.time() + ttl if ttl else 0,
|
||||
}
|
||||
|
||||
async def dash_cast(call: ServiceCall):
|
||||
link_id = uuid.uuid4().hex
|
||||
LINKS[link_id] = {
|
||||
"url": call.data.get("url"), # camera URL (rtsp...)
|
||||
"entity": call.data.get("entity"), # camera entity id
|
||||
"limit": 1, # 1 attempt
|
||||
"ts": time.time() + 30, # for 30 seconds
|
||||
}
|
||||
|
||||
hass_url = call.data.get("hass_url") or get_url(hass)
|
||||
query = call.data.get("extra", {})
|
||||
query["url"] = link_id
|
||||
cast_url = hass_url + "/webrtc/embed?" + urlencode(query)
|
||||
|
||||
_LOGGER.debug(f"dash_cast: {cast_url}")
|
||||
|
||||
await hass.async_add_executor_job(
|
||||
utils.dash_cast,
|
||||
hass,
|
||||
call.data[ATTR_ENTITY_ID],
|
||||
cast_url,
|
||||
call.data.get("force", False),
|
||||
)
|
||||
|
||||
hass.services.async_register(DOMAIN, "create_link", create_link, CREATE_LINK_SCHEMA)
|
||||
hass.services.async_register(DOMAIN, "dash_cast", dash_cast, DASH_CAST_SCHEMA)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
# 1. If user set custom url
|
||||
go_url = entry.data.get(CONF_URL)
|
||||
|
||||
# 2. Check if go2rtc running on same server
|
||||
if not go_url:
|
||||
go_url = await utils.check_go2rtc(hass)
|
||||
|
||||
if go_url:
|
||||
# netloc example: admin:admin@192.168.1.123:1984
|
||||
hass.data[DOMAIN] = go_url
|
||||
return True
|
||||
|
||||
# 3. Serve go2rtc binary manually
|
||||
binary = await hass.async_add_executor_job(utils.validate_binary, hass)
|
||||
if not binary:
|
||||
return False
|
||||
|
||||
hass.data[DOMAIN] = server = Server(binary)
|
||||
server.start()
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, server.stop)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
server = hass.data[DOMAIN]
|
||||
if isinstance(server, Server):
|
||||
server.stop()
|
||||
return True
|
||||
|
||||
|
||||
async def ws_connect(hass: HomeAssistant, params: dict) -> str:
|
||||
# 1. Server URL from card param
|
||||
server: str = params.get("server")
|
||||
# 2. Server URL from integration settings
|
||||
if not server:
|
||||
server: str | Server = hass.data[DOMAIN]
|
||||
# 3. Server is manual binary
|
||||
if isinstance(server, Server):
|
||||
assert server.available, "WebRTC server not available"
|
||||
server = "http://localhost:1984/"
|
||||
|
||||
if entity_id := params.get("entity"):
|
||||
src = await async_get_stream_source(hass, entity_id)
|
||||
if src is None:
|
||||
# build link to MJPEG stream
|
||||
if state := hass.states.get(entity_id):
|
||||
if token := state.attributes.get("access_token"):
|
||||
src = f"{get_url(hass)}/api/camera_proxy_stream/{entity_id}?token={token}"
|
||||
assert src, f"Can't get URL for {entity_id}"
|
||||
query = {"src": src, "name": entity_id}
|
||||
elif src := params.get("url"):
|
||||
if "{{" in src or "{%" in src:
|
||||
src = Template(src, hass).async_render()
|
||||
query = {"src": src}
|
||||
else:
|
||||
raise Exception("Missing url or entity")
|
||||
|
||||
return urljoin("ws" + server[4:], "api/ws") + "?" + urlencode(query)
|
||||
|
||||
|
||||
def _get_image_from_entity_id(hass: HomeAssistant, entity_id: str):
|
||||
"""Get camera component from entity_id."""
|
||||
if (component := hass.data.get("image")) is None:
|
||||
raise Exception("Image integration not set up")
|
||||
|
||||
if (image := component.get_entity(entity_id)) is None:
|
||||
raise Exception("Image not found")
|
||||
|
||||
return image
|
||||
|
||||
|
||||
async def ws_poster(hass: HomeAssistant, params: dict) -> web.Response:
|
||||
poster: str = params["poster"]
|
||||
|
||||
if "{{" in poster or "{%" in poster:
|
||||
# support Jinja2 tempaltes inside poster
|
||||
poster = Template(poster, hass).async_render()
|
||||
|
||||
if poster.startswith("camera."):
|
||||
# support entity_id as poster
|
||||
image = await async_get_image(hass, poster)
|
||||
return web.Response(body=image.content, content_type=image.content_type)
|
||||
|
||||
if poster.startswith("image."):
|
||||
# support entity_id as poster
|
||||
image_entity = _get_image_from_entity_id(hass, poster)
|
||||
image = await image_entity.async_image()
|
||||
_LOGGER.debug(f"webrtc image_entity: {image_entity} - {len(image)}")
|
||||
return web.Response(body=image, content_type="image/jpeg")
|
||||
|
||||
# support poster from go2rtc stream name
|
||||
entry = hass.data[DOMAIN]
|
||||
url = "http://localhost:1984/" if isinstance(entry, Server) else entry
|
||||
url = urljoin(url, "api/frame.jpeg") + "?" + urlencode({"src": poster})
|
||||
|
||||
async with async_get_clientsession(hass).get(url) as r:
|
||||
body = await r.read()
|
||||
return web.Response(body=body, content_type=r.content_type)
|
||||
|
||||
|
||||
class WebSocketView(HomeAssistantView):
|
||||
url = "/api/webrtc/ws"
|
||||
name = "api:webrtc:ws"
|
||||
requires_auth = False
|
||||
|
||||
async def get(self, request: web.Request):
|
||||
params = request.query
|
||||
_LOGGER.debug(f"New client: {dict(params)}")
|
||||
|
||||
if request.query.get("embed"):
|
||||
link_id = request.query.get("url")
|
||||
if link_id not in LINKS:
|
||||
raise HTTPNotFound()
|
||||
|
||||
link = LINKS[link_id]
|
||||
if link["ts"] and time.time() > link["ts"]:
|
||||
LINKS.pop(link_id)
|
||||
raise HTTPGone()
|
||||
|
||||
if link["limit"]:
|
||||
link["limit"] -= 1
|
||||
if link["limit"] == 0:
|
||||
LINKS.pop(link_id)
|
||||
|
||||
params = link
|
||||
|
||||
# fix for https://github.com/AlexxIT/WebRTC/pull/320
|
||||
elif not utils.validate_signed_request(request):
|
||||
# you shall not pass
|
||||
raise HTTPUnauthorized()
|
||||
|
||||
hass = request.app["hass"]
|
||||
|
||||
if "poster" in params:
|
||||
return await ws_poster(hass, params)
|
||||
|
||||
ws_server = web.WebSocketResponse(autoclose=False, autoping=False)
|
||||
ws_server.set_cookie(HLS_COOKIE, HLS_SESSION)
|
||||
await ws_server.prepare(request)
|
||||
|
||||
try:
|
||||
url = await ws_connect(hass, params)
|
||||
|
||||
remote = request.headers.get("X-Forwarded-For")
|
||||
remote = remote + ", " + request.remote if remote else request.remote
|
||||
|
||||
# https://www.nginx.com/resources/wiki/start/topics/examples/forwarded/
|
||||
async with async_get_clientsession(hass).ws_connect(
|
||||
url,
|
||||
autoclose=False,
|
||||
autoping=False,
|
||||
headers={
|
||||
"User-Agent": request.headers.get("User-Agent"),
|
||||
"X-Forwarded-For": remote,
|
||||
"X-Forwarded-Host": request.host,
|
||||
"X-Forwarded-Proto": request.scheme,
|
||||
},
|
||||
) as ws_client:
|
||||
# Proxy requests
|
||||
await asyncio.wait(
|
||||
[
|
||||
asyncio.create_task(utils.websocket_forward(ws_server, ws_client)),
|
||||
asyncio.create_task(utils.websocket_forward(ws_client, ws_server)),
|
||||
],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await ws_server.send_json({"type": "error", "value": str(e)})
|
||||
|
||||
return ws_server
|
||||
|
||||
|
||||
class HLSView(HomeAssistantView):
|
||||
url = "/api/webrtc/hls/{filename}"
|
||||
name = "api:webrtc:hls"
|
||||
requires_auth = False
|
||||
|
||||
async def get(self, request: web.Request, filename: str):
|
||||
if request.cookies.get(HLS_COOKIE) != HLS_SESSION:
|
||||
raise HTTPUnauthorized()
|
||||
|
||||
if filename not in ("playlist.m3u8", "init.mp4", "segment.m4s", "segment.ts"):
|
||||
raise HTTPNotFound()
|
||||
|
||||
hass: HomeAssistant = request.app["hass"]
|
||||
entry = hass.data[DOMAIN]
|
||||
url = "http://localhost:1984/" if isinstance(entry, Server) else entry
|
||||
url = urljoin(url, "api/hls/" + filename) + "?" + request.query_string
|
||||
|
||||
async with async_get_clientsession(hass).get(url) as r:
|
||||
if not r.ok:
|
||||
raise HTTPNotFound()
|
||||
|
||||
body = await r.read()
|
||||
return web.Response(body=body, content_type=r.content_type)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,106 @@
|
||||
import asyncio
|
||||
import os.path
|
||||
import platform
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import voluptuous as vol
|
||||
import yaml
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
from homeassistant.const import CONF_URL, CONF_USERNAME, CONF_PASSWORD
|
||||
|
||||
from . import DOMAIN, utils
|
||||
|
||||
|
||||
class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
async def async_step_user(self, user_input=None):
|
||||
# check if only one integration instance
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
# check if we support this arch
|
||||
if not utils.get_arch():
|
||||
return self.async_abort(
|
||||
reason="arch",
|
||||
description_placeholders={"uname": str(platform.uname())},
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
url = user_input.get(CONF_URL)
|
||||
if not url:
|
||||
# create config file first time
|
||||
config = self.hass.config.path("go2rtc.yaml")
|
||||
if not os.path.isfile(config):
|
||||
return await self.async_step_config(None)
|
||||
|
||||
# check if go2rtc url from user input available
|
||||
elif not await utils.check_go2rtc(self.hass, url):
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_URL, default=url): cv.string,
|
||||
}
|
||||
),
|
||||
errors={"base": "connect"},
|
||||
)
|
||||
|
||||
return self.async_create_entry(title="WebRTC Camera", data=user_input)
|
||||
|
||||
# check if go2rtc already exists on same server
|
||||
tests = await asyncio.gather(
|
||||
utils.check_go2rtc(self.hass),
|
||||
# if go2rtc inside frigate addon with closed public port
|
||||
utils.check_go2rtc(self.hass, "http://ccab4aaf-frigate:1984"),
|
||||
utils.check_go2rtc(self.hass, "http://ccab4aaf-frigate-fa:1984"),
|
||||
utils.check_go2rtc(self.hass, "http://ccab4aaf-frigate-beta:1984"),
|
||||
)
|
||||
|
||||
url = next((url for url in tests if url), vol.UNDEFINED)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_URL, default=url): cv.string,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_config(self, user_input: dict = None):
|
||||
path = self.hass.config.path("go2rtc.yaml")
|
||||
|
||||
if user_input:
|
||||
config = {}
|
||||
|
||||
if not user_input["api"]:
|
||||
config.setdefault("api", {}).setdefault("listen", "127.0.0.1:1984")
|
||||
|
||||
if not user_input["rtsp"]:
|
||||
config.setdefault("rtsp", {}).setdefault("listen", "127.0.0.1:8554")
|
||||
|
||||
if user := user_input.get(CONF_USERNAME):
|
||||
config.setdefault("api", {}).setdefault("username", user)
|
||||
config.setdefault("rtsp", {}).setdefault("username", user)
|
||||
|
||||
if pasw := user_input.get(CONF_PASSWORD):
|
||||
config.setdefault("api", {}).setdefault("password", pasw)
|
||||
config.setdefault("rtsp", {}).setdefault("password", pasw)
|
||||
|
||||
if config:
|
||||
with open(path, "w") as f:
|
||||
yaml.dump(config, f)
|
||||
|
||||
return self.async_create_entry(title="WebRTC Camera", data={})
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="config",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required("api", default=True): cv.boolean,
|
||||
vol.Required("rtsp", default=True): cv.boolean,
|
||||
vol.Optional(CONF_USERNAME): cv.string,
|
||||
vol.Optional(CONF_PASSWORD): cv.string,
|
||||
}
|
||||
),
|
||||
description_placeholders={"path": path},
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"domain": "webrtc",
|
||||
"name": "WebRTC Camera",
|
||||
"codeowners": [
|
||||
"@AlexxIT"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"http",
|
||||
"lovelace"
|
||||
],
|
||||
"documentation": "https://github.com/AlexxIT/WebRTC",
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/AlexxIT/WebRTC/issues",
|
||||
"requirements": [],
|
||||
"version": "v3.6.1"
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
async_process_play_media_url,
|
||||
BrowseMedia,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
PLATFORM_SCHEMA,
|
||||
)
|
||||
from homeassistant.const import STATE_PLAYING, STATE_IDLE, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.reload import async_setup_reload_service
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import utils
|
||||
from .utils import DOMAIN
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Required("stream"): cv.string,
|
||||
vol.Required("audio"): cv.string,
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None
|
||||
) -> None:
|
||||
await async_setup_reload_service(hass, DOMAIN, ["media_player"])
|
||||
|
||||
player = WebRTCPlayer(**config)
|
||||
|
||||
async_add_entities([player])
|
||||
|
||||
|
||||
class WebRTCPlayer(MediaPlayerEntity):
|
||||
def __init__(self, name: str, stream: str, audio: str, **kwargs):
|
||||
self._attr_supported_features = (
|
||||
MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
| MediaPlayerEntityFeature.STOP
|
||||
)
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = stream
|
||||
self.audio = audio
|
||||
|
||||
async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> None:
|
||||
if media_source.is_media_source_id(media_id):
|
||||
sourced_media = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = sourced_media.url
|
||||
|
||||
media_id = async_process_play_media_url(self.hass, media_id)
|
||||
if not media_type.startswith("#"):
|
||||
media_type = "#input=file"
|
||||
|
||||
r = await async_get_clientsession(self.hass).post(
|
||||
utils.api_streams(self.hass),
|
||||
params={
|
||||
"dst": self.unique_id,
|
||||
"src": f"ffmpeg:{media_id}#audio={self.audio}{media_type}",
|
||||
},
|
||||
timeout=9,
|
||||
)
|
||||
assert r.ok
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
r = await async_get_clientsession(self.hass).post(
|
||||
utils.api_streams(self.hass),
|
||||
params={"dst": self.unique_id, "src": ""},
|
||||
timeout=3,
|
||||
)
|
||||
assert r.ok
|
||||
|
||||
async def async_update(self):
|
||||
try:
|
||||
r = await async_get_clientsession(self.hass).get(
|
||||
utils.api_streams(self.hass), params={"src": self.unique_id}, timeout=9
|
||||
)
|
||||
self._attr_available = r.ok
|
||||
resp = await r.json(content_type=None)
|
||||
playing = any("type" in p for p in resp["producers"])
|
||||
self._attr_state = STATE_PLAYING if playing else STATE_IDLE
|
||||
except:
|
||||
pass
|
||||
|
||||
async def async_browse_media(
|
||||
self, media_content_type: str = None, media_content_id: str = None
|
||||
) -> BrowseMedia:
|
||||
return await media_source.async_browse_media(self.hass, media_content_id)
|
||||
@@ -0,0 +1,59 @@
|
||||
create_link:
|
||||
fields:
|
||||
link_id:
|
||||
example: fd0a53ca-e9ab-4e7a-86a2-441642b16ae1
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
url:
|
||||
example: rtsp://rtsp:12345678@192.168.1.123:554/av_stream/ch0
|
||||
selector:
|
||||
text:
|
||||
entity:
|
||||
example: camera.generic_stream
|
||||
selector:
|
||||
entity:
|
||||
domain: camera
|
||||
open_limit:
|
||||
default: 1
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
unit_of_measurement: times
|
||||
time_to_live:
|
||||
default: 60
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100000
|
||||
unit_of_measurement: seconds
|
||||
|
||||
dash_cast:
|
||||
fields:
|
||||
entity_id:
|
||||
example: media_player.mibox4
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: cast
|
||||
domain: media_player
|
||||
url:
|
||||
example: rtsp://rtsp:12345678@192.168.1.123:554/av_stream/ch0
|
||||
selector:
|
||||
text:
|
||||
entity:
|
||||
example: camera.generic_stream
|
||||
selector:
|
||||
entity:
|
||||
domain: camera
|
||||
extra:
|
||||
selector:
|
||||
object:
|
||||
force:
|
||||
selector:
|
||||
boolean:
|
||||
hass_url:
|
||||
example: http://192.168.1.123:8123
|
||||
selector:
|
||||
text:
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"arch": "Unsupported OS architecture: {uname}",
|
||||
"single_instance_allowed": "Already configured. Only a single configuration possible."
|
||||
},
|
||||
"error": {
|
||||
"connect": "Can't connect to go2rtc server"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "go2rtc url",
|
||||
"description": "Select [go2rtc](https://github.com/AlexxIT/go2rtc) streaming server version:\n1. Leave blank if you want the built-in go2rtc version (*basic users*)\n2. Set `http://localhost:1984` if you install [go2rtc](https://github.com/AlexxIT/go2rtc#go2rtc-home-assistant-add-on) or [Frigate 12+](https://docs.frigate.video/) add-on (*advanced users*)\n3. Set custom address if you install go2rtc on another server (*hackers*)",
|
||||
"data": {
|
||||
"url": "URL"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"title": "go2rtc config",
|
||||
"description": "Path: `{path}`",
|
||||
"data": {
|
||||
"api": "Public WebUI on port 1984",
|
||||
"rtsp": "Public RTSP on port 8554",
|
||||
"username": "Public Username (Web and RTSP)",
|
||||
"password": "Public Password (Web and RTSP)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"create_link": {
|
||||
"name": "Create Link",
|
||||
"description": "Create a temporary or permanent link to a stream (enter `url` or `entity`)",
|
||||
"fields": {
|
||||
"link_id": {
|
||||
"name": "Link ID",
|
||||
"description": "Create a random or permanent ID for your link"
|
||||
},
|
||||
"url": {
|
||||
"name": "URL",
|
||||
"description": "Link to RTSP-stream"
|
||||
},
|
||||
"entity": {
|
||||
"name": "Entity",
|
||||
"description": "Camera entity_id"
|
||||
},
|
||||
"open_limit": {
|
||||
"name": "Open limit",
|
||||
"description": "How many times a link can be opened (0 - unlimit)"
|
||||
},
|
||||
"time_to_live": {
|
||||
"name": "Time to live",
|
||||
"description": "How many seconds will the link live (0 - unlimit)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dash_cast": {
|
||||
"name": "DashCast",
|
||||
"description": "Cast stream to Chromecast device via DashCast application",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Media Entity",
|
||||
"description": "Media player entity_id"
|
||||
},
|
||||
"url": {
|
||||
"name": "URL",
|
||||
"description": "Link to RTSP-stream"
|
||||
},
|
||||
"entity": {
|
||||
"name": "Entity",
|
||||
"description": "Camera entity_id"
|
||||
},
|
||||
"extra": {
|
||||
"name": "Extra",
|
||||
"description": "Additional card params"
|
||||
},
|
||||
"force": {
|
||||
"name": "Force",
|
||||
"description": "Force restart DashCast application"
|
||||
},
|
||||
"hass_url": {
|
||||
"name": "Hass URL",
|
||||
"description": "Manual base URL to Hass server"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"arch": "Arquitectura del sistema operativo no compatible: {uname}",
|
||||
"single_instance_allowed": "Ya está configurado. Solo es posible una única configuración."
|
||||
},
|
||||
"error": {
|
||||
"connect": "No se puede conectar al servidor go2rtc"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "URL de go2rtc",
|
||||
"description": "Selecciona la versión del servidor de streaming [go2rtc](https://github.com/AlexxIT/go2rtc):\n1. Deja vacío si quieres la versión go2rtc integrada (*usuarios básicos*)\n2. Establece `http://localhost:1984` si instalas el complemento [go2rtc](https://github.com/AlexxIT/go2rtc#go2rtc-home-assistant-add-on) o [Frigate 12+](https://docs.frigate.video/) (*usuarios avanzados*)\n3. Establece una dirección personalizada si instalas go2rtc en otro servidor (*hackers*)",
|
||||
"data": {
|
||||
"url": "URL"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"title": "Configuración de go2rtc",
|
||||
"description": "Ruta: `{path}`",
|
||||
"data": {
|
||||
"api": "WebUI pública en el puerto 1984",
|
||||
"rtsp": "RTSP público en el puerto 8554",
|
||||
"username": "Nombre de usuario público (Web y RTSP)",
|
||||
"password": "Contraseña pública (Web y RTSP)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"create_link": {
|
||||
"name": "Crear enlace",
|
||||
"description": "Crea un enlace temporal o permanente a un stream (ingresa `url` o `entidad`)",
|
||||
"fields": {
|
||||
"link_id": {
|
||||
"name": "ID del enlace",
|
||||
"description": "Crea una ID aleatoria o permanente para tu enlace"
|
||||
},
|
||||
"url": {
|
||||
"name": "URL",
|
||||
"description": "Enlace al stream RTSP"
|
||||
},
|
||||
"entity": {
|
||||
"name": "Entidad",
|
||||
"description": "entity_id de la cámara"
|
||||
},
|
||||
"open_limit": {
|
||||
"name": "Límite de aperturas",
|
||||
"description": "Cuántas veces se puede abrir un enlace (0 - ilimitado)"
|
||||
},
|
||||
"time_to_live": {
|
||||
"name": "Tiempo de vida",
|
||||
"description": "Cuántos segundos vivirá el enlace (0 - ilimitado)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dash_cast": {
|
||||
"name": "DashCast",
|
||||
"description": "Transmite stream al dispositivo Chromecast a través de la aplicación DashCast",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Entidad multimedia",
|
||||
"description": "entity_id del reproductor multimedia"
|
||||
},
|
||||
"url": {
|
||||
"name": "URL",
|
||||
"description": "Enlace al stream RTSP"
|
||||
},
|
||||
"entity": {
|
||||
"name": "Entidad",
|
||||
"description": "entity_id de la cámara"
|
||||
},
|
||||
"extra": {
|
||||
"name": "Extra",
|
||||
"descripción": "Parámetros adicionales de la tarjeta"
|
||||
},
|
||||
"force": {
|
||||
"name": "Forzar",
|
||||
"description": "Forzar el reinicio de la aplicación DashCast"
|
||||
},
|
||||
"hass_url": {
|
||||
"name": "URL de Hass",
|
||||
"description": "URL base manual al servidor Hass"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"arch": "Architecture de l'OS non prise en charge : {uname}",
|
||||
"single_instance_allowed": "Déjà configuré. Une seule configuration possible."
|
||||
},
|
||||
"error": {
|
||||
"connect": "Impossible de se connecter au serveur go2rtc"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "URL de go2rtc",
|
||||
"description": "Sélectionnez la version du serveur de streaming [go2rtc](https://github.com/AlexxIT/go2rtc) :\n1. Laissez vide si vous souhaitez la version intégrée de go2rtc (*utilisateurs basiques*).\n2. Définissez « http://localhost:1984 » si vous installez le module complémentaire [go2rtc](https://github.com/AlexxIT/go2rtc#go2rtc-home-assistant-add-on) ou [Frigate 12+](https://docs.frigate.video/) (*utilisateurs avancés*).\n3. Définissez une adresse personnalisée si vous installez go2rtc sur un autre serveur (*hackers*).",
|
||||
"data": {
|
||||
"url": "URL"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"title": "configuration de go2rtc",
|
||||
"description": "Chemin : `{path}`",
|
||||
"data": {
|
||||
"api": "Interface Web publique sur le port 1984",
|
||||
"rtsp": "RTSP public sur le port 8554",
|
||||
"username": "Nom d'utilisateur public (Web et RTSP)",
|
||||
"password": "Mot de passe public (Web et RTSP)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"create_link": {
|
||||
"name": "Créer un lien",
|
||||
"description": "Créez un lien temporaire ou permanent vers un flux (saisissez `url` ou `entity`)",
|
||||
"fields": {
|
||||
"link_id": {
|
||||
"name": "ID du lien",
|
||||
"description": "Créez un ID aléatoire ou permanent pour votre lien"
|
||||
},
|
||||
"url": {
|
||||
"name": "URL",
|
||||
"description": "Lien vers le flux RTSP"
|
||||
},
|
||||
"entity": {
|
||||
"name": "Entité",
|
||||
"description": "Caméra entity_id"
|
||||
},
|
||||
"open_limit": {
|
||||
"name": "Limite ouverte",
|
||||
"description": "Combien de fois un lien peut être ouvert (0 - illimité)"
|
||||
},
|
||||
"time_to_live": {
|
||||
"name": "Temps du direct",
|
||||
"description": "Combien de secondes le lien restera-t-il actif (0 - illimité)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dash_cast": {
|
||||
"name": "DashCast",
|
||||
"description": "Diffuser le flux sur un appareil Chromecast via l'application DashCast",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Entité multimédia",
|
||||
"description": "Multimédia entity_id"
|
||||
},
|
||||
"url": {
|
||||
"name": "URL",
|
||||
"description": "Link to RTSP-stream"
|
||||
},
|
||||
"entity": {
|
||||
"name": "Entité",
|
||||
"description": "Caméra entity_id"
|
||||
},
|
||||
"extra": {
|
||||
"name": "Supplément",
|
||||
"description": "Paramètres de carte supplémentaires"
|
||||
},
|
||||
"force": {
|
||||
"name": "Forcer",
|
||||
"description": "Forcer le redémarrage de l'application DashCast"
|
||||
},
|
||||
"hass_url": {
|
||||
"name": "URL Hass",
|
||||
"description": "URL de base manuelle vers le serveur Hass"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"arch": "Arquitetura de SO não suportada: {uname}",
|
||||
"single_instance_allowed": "Já configurado. Apenas é possível uma única configuração."
|
||||
},
|
||||
"error": {
|
||||
"connect": "Não é possível ligar ao servidor go2rtc"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "URL do go2rtc",
|
||||
"description": "Selecione a versão do servidor de streaming [go2rtc](https://github.com/AlexxIT/go2rtc):\n1. Deixe em branco se desejar utilizar a versão go2rtc incorporada (*utilizadores básicos*)\n2. Defina `http://localhost:1984` se instalar o add-on [go2rtc](https://github.com/AlexxIT/go2rtc#go2rtc-home-assistant-add-on) ou [Frigate 12+](https://docs.frigate.video/) (*utilizadores avançados*)\n3. Defina um endereço personalizado se instalar go2rtc num outro servidor (*utilizadores experientes*)",
|
||||
"data": {
|
||||
"url": "URL"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"title": "Configuração go2rtc",
|
||||
"description": "Caminho: `{path}`",
|
||||
"data": {
|
||||
"api": "Public WebUI na porta 1984",
|
||||
"rtsp": "RTSP público na porta 8554",
|
||||
"username": "Nome de utilizador público (Web e RTSP)",
|
||||
"password": "Palavra-passe pública (Web e RTSP)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"create_link": {
|
||||
"name": "Criar Link",
|
||||
"description": "Crie um link temporário ou permanente para um stream (introduza `url` ou `entidade`)",
|
||||
"fields": {
|
||||
"link_id": {
|
||||
"name": "ID do Link",
|
||||
"description": "Crie um ID aleatório ou permanente para o seu link"
|
||||
},
|
||||
"url": {
|
||||
"name": "URL",
|
||||
"description": "Link para o stream RTSP"
|
||||
},
|
||||
"entity": {
|
||||
"name": "Entidade",
|
||||
"description": "ID de entidade da câmara"
|
||||
},
|
||||
"open_limit": {
|
||||
"name": "Limite de aberturas",
|
||||
"description": "Quantas vezes o link pode ser aberto (0 - sem limite)"
|
||||
},
|
||||
"time_to_live": {
|
||||
"name": "Tempo de vida",
|
||||
"description": "Quantos segundos o link permanecerá ativo (0 - sem limite)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dash_cast": {
|
||||
"name": "DashCast",
|
||||
"description": "Transmitir stream para dispositivo Chromecast através da aplicação DashCast",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Entidade de Média",
|
||||
"description": "ID de entidade do leitor de média"
|
||||
},
|
||||
"url": {
|
||||
"name": "URL",
|
||||
"description": "Link para o stream RTSP"
|
||||
},
|
||||
"entity": {
|
||||
"name": "Entidade",
|
||||
"description": "ID de entidade da câmara"
|
||||
},
|
||||
"extra": {
|
||||
"name": "Extra",
|
||||
"description": "Parâmetros adicionais do cartão"
|
||||
},
|
||||
"force": {
|
||||
"name": "Forçar",
|
||||
"description": "Forçar reinício da aplicação DashCast"
|
||||
},
|
||||
"hass_url": {
|
||||
"name": "URL do Hass",
|
||||
"description": "URL base manual para o servidor Hass"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"arch": "غیر تعاون یافتہ OS آرکیٹیکچر: {uname}",
|
||||
"single_instance_allowed": "پہلے سے ترتیب شدہ۔ صرف ایک ہی ترتیب ممکن ہے۔."
|
||||
},
|
||||
"error": {
|
||||
"connect": "go2rtc سرور سے منسلک نہیں ہو سکتا"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "go2rtc یو آر ایل",
|
||||
"description": "[go2rtc](https://github.com/AlexxIT/go2rtc) اسٹریمنگ سرور ورژن منتخب کریں:\n1۔ اگر آپ بلٹ ان go2rtc ورژن (*بنیادی صارفین*) چاہتے ہیں تو خالی چھوڑ دیں\n2۔ اگر آپ [go2rtc](https://github.com/AlexxIT/go2rtc#go2rtc-home-assistant-add-on) یا [Frigate 12+](https://) کو انسٹال کرتے ہیں تو `http://localhost:1984` سیٹ کریں docs.frigate.video/) ایڈ آن (*جدید صارفین*)\n3۔ اگر آپ کسی دوسرے سرور (*ہیکرز*) پر go2rtc انسٹال کرتے ہیں تو اپنی مرضی کے مطابق پتہ سیٹ کریں۔",
|
||||
"data": {
|
||||
"url": "URL"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"title": "go2rtc تشکیل",
|
||||
"description": "راستہ: `{path}`",
|
||||
"data": {
|
||||
"api": "Public WebUI on port 1984",
|
||||
"rtsp": "Public RTSP on port 8554",
|
||||
"username": "عوامی صارف کا نام (ویب اور آر ٹی ایس پی)",
|
||||
"password": "پبلک پاس ورڈ (ویب اور آر ٹی ایس پی)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"create_link": {
|
||||
"name": "لنک بنائیں",
|
||||
"description": "سٹریم کا عارضی یا مستقل لنک بنائیں ('url' یا 'entity' درج کریں)",
|
||||
"fields": {
|
||||
"link_id": {
|
||||
"name": "لنک آئی ڈی",
|
||||
"description": "اپنے لنک کے لیے ایک بے ترتیب یا مستقل ID بنائیں"
|
||||
},
|
||||
"url": {
|
||||
"name": "URL",
|
||||
"description": "RTSP اسٹریم سے لنک کریں۔"
|
||||
},
|
||||
"entity": {
|
||||
"name": "ہستی",
|
||||
"description": "کیمرہ entity_id"
|
||||
},
|
||||
"open_limit": {
|
||||
"name": "کھلی حد",
|
||||
"description": "ایک لنک کتنی بار کھولا جا سکتا ہے (0 - لا محدود)"
|
||||
},
|
||||
"time_to_live": {
|
||||
"name": "زندہ رہنے کا وقت",
|
||||
"description": "لنک کتنے سیکنڈ تک زندہ رہے گا (0 - لامحدود)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dash_cast": {
|
||||
"name": "ڈیش کاسٹ",
|
||||
"description": "DashCast ایپلیکیشن کے ذریعے کروم کاسٹ ڈیوائس پر اسٹریم کاسٹ کریں۔",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "میڈیا ہستی",
|
||||
"description": "میڈیا پلیئر کی entity_id"
|
||||
},
|
||||
"url": {
|
||||
"name": "URL",
|
||||
"description": "RTSP اسٹریم سے لنک کریں۔"
|
||||
},
|
||||
"entity": {
|
||||
"name": "ہستی",
|
||||
"description": "کیمرہ entity_id"
|
||||
},
|
||||
"extra": {
|
||||
"name": "اضافی",
|
||||
"description": "کارڈ کے اضافی پیرامز"
|
||||
},
|
||||
"force": {
|
||||
"name": "زبردستی",
|
||||
"description": "ڈیش کاسٹ ایپلیکیشن کو زبردستی دوبارہ شروع کریں۔"
|
||||
},
|
||||
"hass_url": {
|
||||
"name": " بیس یو آر ایل",
|
||||
"description": "ہاس سرور کے لیے دستی بیس یو آر ایل"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import zipfile
|
||||
from threading import Thread
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import aiohttp
|
||||
import jwt
|
||||
import requests
|
||||
from aiohttp import web
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.components.http.auth import DATA_SIGN_SECRET, SIGN_QUERY_PARAM
|
||||
from homeassistant.components.lovelace.resources import ResourceStorageCollection
|
||||
from homeassistant.const import MAJOR_VERSION, MINOR_VERSION
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity_component import DATA_INSTANCES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "webrtc"
|
||||
|
||||
BINARY_VERSION = "1.9.9"
|
||||
|
||||
SYSTEM = {
|
||||
"Windows": {"AMD64": "go2rtc_win64.zip", "ARM64": "go2rtc_win_arm64.zip"},
|
||||
"Darwin": {"x86_64": "go2rtc_mac_amd64.zip", "arm64": "go2rtc_mac_arm64.zip"},
|
||||
"Linux": {
|
||||
"armv7l": "go2rtc_linux_arm",
|
||||
"armv8l": "go2rtc_linux_arm", # https://github.com/AlexxIT/WebRTC/issues/18
|
||||
"aarch64": "go2rtc_linux_arm64",
|
||||
"x86_64": "go2rtc_linux_amd64",
|
||||
"i386": "go2rtc_linux_386",
|
||||
"i486": "go2rtc_linux_386",
|
||||
"i586": "go2rtc_linux_386",
|
||||
"i686": "go2rtc_linux_386",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_URL = "http://localhost:1984/"
|
||||
|
||||
BINARY_NAME = re.compile(
|
||||
r"^(go2rtc-\d\.\d\.\d+|go2rtc_v0\.1-rc\.[5-9]|rtsp2webrtc_v[1-5])(\.exe)?$"
|
||||
)
|
||||
|
||||
|
||||
def get_arch() -> Optional[str]:
|
||||
system = SYSTEM.get(platform.system())
|
||||
if not system:
|
||||
return None
|
||||
return system.get(platform.machine())
|
||||
|
||||
|
||||
def unzip(content: bytes) -> bytes:
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as zf:
|
||||
for filename in zf.namelist():
|
||||
with zf.open(filename) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def validate_binary(hass: HomeAssistant) -> Optional[str]:
|
||||
filename = f"go2rtc-{BINARY_VERSION}"
|
||||
if platform.system() == "Windows":
|
||||
filename += ".exe"
|
||||
|
||||
filename = hass.config.path(filename)
|
||||
try:
|
||||
if os.path.isfile(filename) and subprocess.check_output(
|
||||
[filename, "-v"]
|
||||
).startswith(b"go2rtc"):
|
||||
return filename
|
||||
except:
|
||||
pass
|
||||
|
||||
# remove all old binaries
|
||||
for file in os.listdir(hass.config.config_dir):
|
||||
if BINARY_NAME.match(file):
|
||||
_LOGGER.debug(f"Remove old binary: {file}")
|
||||
os.remove(hass.config.path(file))
|
||||
|
||||
# download new binary
|
||||
url = (
|
||||
f"https://github.com/AlexxIT/go2rtc/releases/download/"
|
||||
f"v{BINARY_VERSION}/{get_arch()}"
|
||||
)
|
||||
_LOGGER.debug(f"Download new binary: {url}")
|
||||
r = requests.get(url)
|
||||
if not r.ok:
|
||||
return None
|
||||
|
||||
raw = r.content
|
||||
|
||||
# unzip binary for windows
|
||||
if url.endswith(".zip"):
|
||||
raw = unzip(raw)
|
||||
|
||||
# save binary to config folder
|
||||
with open(filename, "wb") as f:
|
||||
f.write(raw)
|
||||
|
||||
# change binary access rights
|
||||
os.chmod(filename, os.stat(filename).st_mode | stat.S_IEXEC)
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
async def register_static_path(hass: HomeAssistant, url_path: str, path: str):
|
||||
if (MAJOR_VERSION, MINOR_VERSION) >= (2024, 7):
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(url_path, path, True)]
|
||||
)
|
||||
else:
|
||||
hass.http.register_static_path(url_path, path)
|
||||
|
||||
|
||||
async def init_resource(hass: HomeAssistant, url: str, ver: str) -> bool:
|
||||
"""Add extra JS module for lovelace mode YAML and new lovelace resource
|
||||
for mode GUI. It's better to add extra JS for all modes, because it has
|
||||
random url to avoid problems with the cache. But chromecast don't support
|
||||
extra JS urls and can't load custom card.
|
||||
"""
|
||||
lovelace = hass.data["lovelace"]
|
||||
resources: ResourceStorageCollection = (
|
||||
lovelace.resources if hasattr(lovelace, "resources") else lovelace["resources"]
|
||||
)
|
||||
|
||||
# force load storage
|
||||
await resources.async_get_info()
|
||||
|
||||
url2 = f"{url}?v={ver}"
|
||||
|
||||
for item in resources.async_items():
|
||||
if not item.get("url", "").startswith(url):
|
||||
continue
|
||||
|
||||
# no need to update
|
||||
if item["url"].endswith(ver):
|
||||
return False
|
||||
|
||||
_LOGGER.debug(f"Update lovelace resource to: {url2}")
|
||||
|
||||
if isinstance(resources, ResourceStorageCollection):
|
||||
await resources.async_update_item(
|
||||
item["id"], {"res_type": "module", "url": url2}
|
||||
)
|
||||
else:
|
||||
# not the best solution, but what else can we do
|
||||
item["url"] = url2
|
||||
|
||||
return True
|
||||
|
||||
if isinstance(resources, ResourceStorageCollection):
|
||||
_LOGGER.debug(f"Add new lovelace resource: {url2}")
|
||||
await resources.async_create_item({"res_type": "module", "url": url2})
|
||||
else:
|
||||
_LOGGER.debug(f"Add extra JS module: {url2}")
|
||||
add_extra_js_url(hass, url2)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# noinspection PyProtectedMember
|
||||
def dash_cast(hass: HomeAssistant, entities: list, url: str, force: bool):
|
||||
"""Cast webpage to chromecast device via DashCast application."""
|
||||
try:
|
||||
for entity in hass.data[DATA_INSTANCES]["media_player"].entities:
|
||||
if entity.entity_id not in entities or not hasattr(entity, "_chromecast"):
|
||||
continue
|
||||
|
||||
if not hasattr(entity, "dashcast"):
|
||||
from pychromecast.controllers.dashcast import DashCastController
|
||||
|
||||
entity.dashcast = DashCastController()
|
||||
entity._chromecast.register_handler(entity.dashcast)
|
||||
|
||||
_LOGGER.debug(f"DashCast to {entity.entity_id}")
|
||||
entity.dashcast.load_url(url, force=force)
|
||||
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Can't DashCast to {entities}", exc_info=e)
|
||||
|
||||
|
||||
def validate_signed_request(request: web.Request) -> bool:
|
||||
try:
|
||||
hass = request.app["hass"]
|
||||
secret = hass.data.get(DATA_SIGN_SECRET)
|
||||
signature = request.query.get(SIGN_QUERY_PARAM)
|
||||
claims = jwt.decode(signature, secret, algorithms=["HS256"])
|
||||
return claims["path"] == request.path
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def check_go2rtc(hass: HomeAssistant, url: str = DEFAULT_URL) -> Optional[str]:
|
||||
session = async_get_clientsession(hass)
|
||||
try:
|
||||
r = await session.head(url, timeout=2, allow_redirects=False)
|
||||
return url if r.status < 300 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def api_streams(hass: HomeAssistant) -> str:
|
||||
entry = hass.data[DOMAIN]
|
||||
go_url = "http://localhost:1984/" if isinstance(entry, Server) else entry
|
||||
return urljoin(go_url, "api/streams")
|
||||
|
||||
|
||||
# copied from homeassistant.components.hassio.ingress import _websocket_forward
|
||||
async def websocket_forward(ws_from, ws_to) -> None:
|
||||
try:
|
||||
async for msg in ws_from:
|
||||
if msg.type is aiohttp.WSMsgType.TEXT:
|
||||
await ws_to.send_str(msg.data)
|
||||
elif msg.type is aiohttp.WSMsgType.BINARY:
|
||||
await ws_to.send_bytes(msg.data)
|
||||
elif msg.type is aiohttp.WSMsgType.PING:
|
||||
await ws_to.ping()
|
||||
elif msg.type is aiohttp.WSMsgType.PONG:
|
||||
await ws_to.pong()
|
||||
elif ws_to.closed:
|
||||
await ws_to.close(code=ws_to.close_code, message=msg.extra) # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
_LOGGER.debug(f"WebSocket forward exception: {repr(e)}")
|
||||
|
||||
|
||||
class Server(Thread):
|
||||
def __init__(self, binary: str):
|
||||
super().__init__(name=DOMAIN, daemon=True)
|
||||
self.binary = binary
|
||||
self.process = None
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
return self.process.poll() is None if self.process else False
|
||||
|
||||
def run(self):
|
||||
while self.binary:
|
||||
self.process = subprocess.Popen(
|
||||
[self.binary], stdout=subprocess.PIPE, stderr=subprocess.STDOUT
|
||||
)
|
||||
|
||||
# check alive
|
||||
while self.process.poll() is None:
|
||||
line = self.process.stdout.readline()
|
||||
if line == b"":
|
||||
break
|
||||
_LOGGER.debug(line[:-1].decode())
|
||||
|
||||
def stop(self, *args):
|
||||
self.binary = None
|
||||
self.process.terminate()
|
||||
@@ -0,0 +1,346 @@
|
||||
// js version generated from https://github.com/dbuezas/pan-zoom-controller/blob/main/src/digital-ptz.ts
|
||||
const ONE_FINGER_ZOOM_SPEED = 1 / 200; // 1 scale every 200px
|
||||
const DBL_CLICK_MS = 400;
|
||||
const MAX_ZOOM = 10;
|
||||
const DEFAULT_OPTIONS = {
|
||||
touch_drag_pan: true,
|
||||
touch_tap_drag_zoom: true,
|
||||
mouse_drag_pan: true,
|
||||
mouse_wheel_zoom: true,
|
||||
mouse_double_click_zoom: true,
|
||||
touch_pinch_zoom: true,
|
||||
persist_key: "",
|
||||
persist: true,
|
||||
};
|
||||
export class DigitalPTZ {
|
||||
constructor(containerEl, transformEl, videoEl, options) {
|
||||
this.offHandles = [];
|
||||
this.recomputeRects = () => {
|
||||
this.transform.updateRects(this.videoEl, this.containerEl);
|
||||
this.transform.zoomAtCoords(1, 0, 0); // clamp transform
|
||||
this.render();
|
||||
};
|
||||
this.render = (transition = false) => {
|
||||
if (transition) {
|
||||
// transition is used to animate dbl click zoom
|
||||
this.transformEl.style.transition = "transform 200ms";
|
||||
setTimeout(() => {
|
||||
this.transformEl.style.transition = "";
|
||||
}, 200);
|
||||
}
|
||||
this.transformEl.style.transform = this.transform.render();
|
||||
};
|
||||
this.containerEl = containerEl;
|
||||
this.transformEl = transformEl;
|
||||
this.videoEl = videoEl;
|
||||
this.options = Object.assign({}, DEFAULT_OPTIONS, options);
|
||||
this.transform = new Transform({
|
||||
persist_key: this.options.persist_key,
|
||||
persist: this.options.persist,
|
||||
});
|
||||
const o = this.options;
|
||||
const gestureParam = {
|
||||
containerEl: this.containerEl,
|
||||
transform: this.transform,
|
||||
render: this.render,
|
||||
};
|
||||
const h = this.offHandles;
|
||||
if (o.mouse_drag_pan) h.push(startMouseDragPan(gestureParam));
|
||||
if (o.mouse_wheel_zoom) h.push(startMouseWheel(gestureParam));
|
||||
if (o.mouse_double_click_zoom) h.push(startDoubleClickZoom(gestureParam));
|
||||
if (o.touch_tap_drag_zoom) h.push(startTouchTapDragZoom(gestureParam));
|
||||
if (o.touch_drag_pan) h.push(startTouchDragPan(gestureParam));
|
||||
if (o.touch_pinch_zoom) h.push(startTouchPinchZoom(gestureParam));
|
||||
this.videoEl.addEventListener("loadedmetadata", this.recomputeRects);
|
||||
this.resizeObserver = new ResizeObserver(this.recomputeRects);
|
||||
this.resizeObserver.observe(this.containerEl);
|
||||
this.recomputeRects();
|
||||
}
|
||||
destroy() {
|
||||
for (const off of this.offHandles) off();
|
||||
this.videoEl.removeEventListener("loadedmetadata", this.recomputeRects);
|
||||
this.resizeObserver.unobserve(this.containerEl);
|
||||
}
|
||||
}
|
||||
/* Gestures */
|
||||
const preventScroll = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
};
|
||||
const getCenter = (touches) => ({
|
||||
x: (touches[0].pageX + touches[1].pageX) / 2,
|
||||
y: (touches[0].pageY + touches[1].pageY) / 2,
|
||||
});
|
||||
const getSpread = (touches) =>
|
||||
Math.hypot(
|
||||
touches[0].pageX - touches[1].pageX,
|
||||
touches[0].pageY - touches[1].pageY
|
||||
);
|
||||
function startTouchPinchZoom({ containerEl, transform, render }) {
|
||||
const onTouchStart = (downEvent) => {
|
||||
const relevant = downEvent.touches.length === 2;
|
||||
if (!relevant) return;
|
||||
let lastTouches = downEvent.touches;
|
||||
const onTouchMove = (moveEvent) => {
|
||||
const newTouches = moveEvent.touches;
|
||||
const oldCenter = getCenter(lastTouches);
|
||||
const newCenter = getCenter(newTouches);
|
||||
const dx = newCenter.x - oldCenter.x;
|
||||
const dy = newCenter.y - oldCenter.y;
|
||||
transform.move(dx, dy);
|
||||
const oldSpread = getSpread(lastTouches);
|
||||
const newSpread = getSpread(newTouches);
|
||||
const zoom = newSpread / oldSpread;
|
||||
transform.zoomAtCoords(zoom, newCenter.x, newCenter.y);
|
||||
lastTouches = moveEvent.touches;
|
||||
render();
|
||||
preventScroll(moveEvent);
|
||||
};
|
||||
const onTouchEnd = () =>
|
||||
containerEl.removeEventListener("touchmove", onTouchMove);
|
||||
containerEl.addEventListener("touchmove", onTouchMove);
|
||||
containerEl.addEventListener("touchend", onTouchEnd, { once: true });
|
||||
};
|
||||
containerEl.addEventListener("touchstart", onTouchStart);
|
||||
return () => containerEl.removeEventListener("touchstart", onTouchStart);
|
||||
}
|
||||
const getDist = (t1, t2) =>
|
||||
Math.hypot(
|
||||
t1.touches[0].pageX - t2.touches[0].pageX,
|
||||
t1.touches[0].pageY - t2.touches[0].pageY
|
||||
);
|
||||
function startTouchTapDragZoom({ containerEl, transform, render }) {
|
||||
let lastEvent;
|
||||
let fastClicks = 0;
|
||||
const onTouchStart = (downEvent) => {
|
||||
const isFastClick =
|
||||
lastEvent && downEvent.timeStamp - lastEvent.timeStamp < DBL_CLICK_MS;
|
||||
if (!isFastClick) fastClicks = 0;
|
||||
fastClicks++;
|
||||
if (downEvent.touches.length > 1) fastClicks = 0;
|
||||
lastEvent = downEvent;
|
||||
};
|
||||
const onTouchMove = (moveEvent) => {
|
||||
if (fastClicks === 2) {
|
||||
const lastY = lastEvent.touches[0].pageY;
|
||||
const currY = moveEvent.touches[0].pageY;
|
||||
transform.zoom(1 - (lastY - currY) * ONE_FINGER_ZOOM_SPEED);
|
||||
lastEvent = moveEvent;
|
||||
render();
|
||||
preventScroll(moveEvent);
|
||||
} else if (getDist(lastEvent, moveEvent) > 10) {
|
||||
fastClicks = 0;
|
||||
}
|
||||
};
|
||||
containerEl.addEventListener("touchmove", onTouchMove);
|
||||
containerEl.addEventListener("touchstart", onTouchStart);
|
||||
return () => {
|
||||
containerEl.removeEventListener("touchmove", onTouchMove);
|
||||
containerEl.removeEventListener("touchstart", onTouchStart);
|
||||
};
|
||||
}
|
||||
function startMouseWheel({ containerEl, transform, render }) {
|
||||
const onWheel = (e) => {
|
||||
const zoom = 1 - e.deltaY / 1000;
|
||||
transform.zoomAtCoords(zoom, e.pageX, e.pageY);
|
||||
render();
|
||||
preventScroll(e);
|
||||
};
|
||||
containerEl.addEventListener("wheel", onWheel);
|
||||
return () => containerEl.removeEventListener("wheel", onWheel);
|
||||
}
|
||||
function startDoubleClickZoom({ containerEl, transform, render }) {
|
||||
let lastDown = 0;
|
||||
let clicks = 0;
|
||||
const onDown = (downEvent) => {
|
||||
const isFastClick = downEvent.timeStamp - lastDown < DBL_CLICK_MS;
|
||||
lastDown = downEvent.timeStamp;
|
||||
if (!isFastClick) clicks = 0;
|
||||
clicks++;
|
||||
if (clicks !== 2) return;
|
||||
const onUp = (upEvent) => {
|
||||
const isQuickRelease = upEvent.timeStamp - lastDown < DBL_CLICK_MS;
|
||||
const dist = Math.hypot(
|
||||
upEvent.pageX - downEvent.pageX,
|
||||
upEvent.pageY - downEvent.pageY
|
||||
);
|
||||
if (!isQuickRelease || dist > 20) return;
|
||||
const zoom = transform.scale == 1 ? 2 : 0.01;
|
||||
transform.zoomAtCoords(zoom, upEvent.pageX, upEvent.pageY);
|
||||
render(true);
|
||||
};
|
||||
window.addEventListener("mouseup", onUp, { once: true });
|
||||
};
|
||||
containerEl.addEventListener("mousedown", onDown);
|
||||
return () => containerEl.removeEventListener("mousedown", onDown);
|
||||
}
|
||||
function startGesturePan({ containerEl, transform, render }, type) {
|
||||
const [downName, moveName, upName] =
|
||||
type === "mouse"
|
||||
? ["mousedown", "mousemove", "mouseup"]
|
||||
: ["touchstart", "touchmove", "touchend"];
|
||||
const isTouchEvent = ev => 'TouchEvent' in window && ev instanceof TouchEvent;
|
||||
const onDown = (downEvt) => {
|
||||
let last = isTouchEvent(downEvt) ? downEvt.touches[0] : downEvt;
|
||||
const onMove = (moveEvt) => {
|
||||
if (isTouchEvent(moveEvt) && moveEvt.touches.length !== 1) return;
|
||||
const curr = isTouchEvent(moveEvt) ? moveEvt.touches[0] : moveEvt;
|
||||
transform.move(curr.pageX - last.pageX, curr.pageY - last.pageY);
|
||||
last = curr;
|
||||
render();
|
||||
if (transform.scale !== 1) preventScroll(moveEvt);
|
||||
};
|
||||
containerEl.addEventListener(moveName, onMove);
|
||||
const onUp = () => containerEl.removeEventListener(moveName, onMove);
|
||||
window.addEventListener(upName, onUp, { once: true });
|
||||
};
|
||||
containerEl.addEventListener(downName, onDown);
|
||||
return () => containerEl.removeEventListener(downName, onDown);
|
||||
}
|
||||
function startTouchDragPan(params) {
|
||||
return startGesturePan(params, "touch");
|
||||
}
|
||||
function startMouseDragPan(params) {
|
||||
return startGesturePan(params, "mouse");
|
||||
}
|
||||
/** Transform */
|
||||
const PERSIST_KEY_PREFIX = "webrtc-digital-ptc:";
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
||||
function getTransformedDimensions(video) {
|
||||
const { videoWidth, videoHeight } = video;
|
||||
if (!videoHeight || !videoWidth) return undefined;
|
||||
var transform = window.getComputedStyle(video).getPropertyValue("transform");
|
||||
const match = transform.match(/matrix\((.+)\)/);
|
||||
if (!match || !match[1]) return { videoWidth, videoHeight }; // the video isn't transformed
|
||||
const matrix = new DOMMatrix(match[1].split(", ").map(Number));
|
||||
const points = [
|
||||
new DOMPoint(0, 0),
|
||||
new DOMPoint(videoWidth, 0),
|
||||
new DOMPoint(0, videoHeight),
|
||||
new DOMPoint(videoWidth, videoHeight),
|
||||
].map((point) => point.matrixTransform(matrix));
|
||||
const minX = Math.min(...points.map((point) => point.x));
|
||||
const maxX = Math.max(...points.map((point) => point.x));
|
||||
const minY = Math.min(...points.map((point) => point.y));
|
||||
const maxY = Math.max(...points.map((point) => point.y));
|
||||
return { videoWidth: maxX - minX, videoHeight: maxY - minY };
|
||||
}
|
||||
class Transform {
|
||||
constructor(settings) {
|
||||
this.scale = 1;
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this.loadPersistedTransform = () => {
|
||||
const { persist_key, persist } = this.settings;
|
||||
if (!persist) return;
|
||||
try {
|
||||
const loaded = JSON.parse(localStorage[persist_key]);
|
||||
const isValid = [loaded.scale, loaded.x, loaded.y].every(
|
||||
Number.isFinite
|
||||
);
|
||||
if (!isValid) {
|
||||
throw new Error("Broken local storage");
|
||||
}
|
||||
this.x = loaded.x;
|
||||
this.y = loaded.y;
|
||||
this.scale = loaded.scale;
|
||||
} catch (e) {
|
||||
delete localStorage[persist_key];
|
||||
}
|
||||
};
|
||||
this.persistTransform = () => {
|
||||
const { persist_key, persist } = this.settings;
|
||||
if (!persist) return;
|
||||
const { x, y, scale } = this;
|
||||
localStorage[persist_key] = JSON.stringify({
|
||||
x,
|
||||
y,
|
||||
scale,
|
||||
});
|
||||
};
|
||||
this.settings = Object.assign(Object.assign({}, settings), {
|
||||
persist_key: PERSIST_KEY_PREFIX + settings.persist_key,
|
||||
});
|
||||
this.loadPersistedTransform();
|
||||
}
|
||||
updateRects(videoEl, containerEl) {
|
||||
const containerRect = containerEl.getBoundingClientRect();
|
||||
if (containerRect.width === 0 || containerRect.height === 0) {
|
||||
// The container rect has no size yet.
|
||||
// This happens when coming back to a tab that was already opened.
|
||||
// The card will get size shortly and the size observer will call this function again.
|
||||
return;
|
||||
}
|
||||
this.containerRect = containerRect;
|
||||
const transformed = getTransformedDimensions(videoEl);
|
||||
if (!transformed) {
|
||||
// The video hasn't loaded yet.
|
||||
// Once it loads, the videometadata listener will call this function again.
|
||||
return;
|
||||
}
|
||||
// When in full screen, and if the aspect ratio of the screen differs from that of the video,
|
||||
// black bars will be shown either to the sides or above/below the video.
|
||||
// This needs to be accounted for when panning, the code below keeps track of that.
|
||||
const screenAspectRatio =
|
||||
this.containerRect.width / this.containerRect.height;
|
||||
const videoAspectRatio = transformed.videoWidth / transformed.videoHeight;
|
||||
if (videoAspectRatio > screenAspectRatio) {
|
||||
// Black bars on the top and bottom
|
||||
const videoHeight = this.containerRect.width / videoAspectRatio;
|
||||
const blackBarHeight = (this.containerRect.height - videoHeight) / 2;
|
||||
this.videoRect = new DOMRect(
|
||||
this.containerRect.x,
|
||||
blackBarHeight + this.containerRect.y,
|
||||
this.containerRect.width,
|
||||
videoHeight
|
||||
);
|
||||
} else {
|
||||
// Black bars on the sides
|
||||
const videoWidth = this.containerRect.height * videoAspectRatio;
|
||||
const blackBarWidth = (this.containerRect.width - videoWidth) / 2;
|
||||
this.videoRect = new DOMRect(
|
||||
blackBarWidth + this.containerRect.x,
|
||||
this.containerRect.y,
|
||||
videoWidth,
|
||||
this.containerRect.height
|
||||
);
|
||||
}
|
||||
}
|
||||
// dx,dy are deltas.
|
||||
move(dx, dy) {
|
||||
if (!this.videoRect) return;
|
||||
const bound = (this.scale - 1) / 2;
|
||||
this.x += dx / this.videoRect.width;
|
||||
this.y += dy / this.videoRect.height;
|
||||
this.x = clamp(this.x, -bound, bound);
|
||||
this.y = clamp(this.y, -bound, bound);
|
||||
this.persistTransform();
|
||||
}
|
||||
// x,y are relative to viewport (clientX, clientY)
|
||||
zoomAtCoords(zoom, x, y) {
|
||||
if (!this.containerRect || !this.videoRect) return;
|
||||
const oldScale = this.scale;
|
||||
this.scale *= zoom;
|
||||
this.scale = clamp(this.scale, 1, MAX_ZOOM);
|
||||
zoom = this.scale / oldScale;
|
||||
x = x - this.containerRect.x - this.containerRect.width / 2;
|
||||
y = y - this.containerRect.y - this.containerRect.height / 2;
|
||||
const dx = x - this.x * this.videoRect.width;
|
||||
const dy = y - this.y * this.videoRect.height;
|
||||
this.move(dx * (1 - zoom), dy * (1 - zoom));
|
||||
}
|
||||
zoom(zoom) {
|
||||
if (!this.containerRect || !this.videoRect) return;
|
||||
const x = this.containerRect.width / 2;
|
||||
const y = this.containerRect.height / 2;
|
||||
this.zoomAtCoords(zoom, x, y);
|
||||
}
|
||||
render() {
|
||||
if (!this.videoRect) return "";
|
||||
const { x, y, scale } = this;
|
||||
return `translate(${x * this.videoRect.width}px, ${
|
||||
y * this.videoRect.height
|
||||
}px) scale(${scale})`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport">
|
||||
<title>WebRTC Camera</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
html, body, webrtc-camera {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
ha-card {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="/webrtc/webrtc-camera.js"></script>
|
||||
<script type="module">
|
||||
const config = {};
|
||||
for (const [k, v] of new URLSearchParams(location.search)) {
|
||||
if (v === 'true') config[k] = true;
|
||||
else if (v === 'false') config[k] = false;
|
||||
else config[k] = v;
|
||||
}
|
||||
|
||||
const card = document.createElement('webrtc-camera');
|
||||
card.setConfig(config);
|
||||
card.hass = {
|
||||
callWS: () => new Promise(resolve => {
|
||||
resolve('');
|
||||
}),
|
||||
hassUrl: () => location.origin + '/api/webrtc/ws?embed=1'
|
||||
};
|
||||
|
||||
document.body.appendChild(card);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,676 @@
|
||||
/**
|
||||
* VideoRTC v1.6.0 - Video player for go2rtc streaming application.
|
||||
*
|
||||
* All modern web technologies are supported in almost any browser except Apple Safari.
|
||||
*
|
||||
* Support:
|
||||
* - ECMAScript 2017 (ES8) = ES6 + async
|
||||
* - RTCPeerConnection for Safari iOS 11.0+
|
||||
* - IntersectionObserver for Safari iOS 12.2+
|
||||
* - ManagedMediaSource for Safari 17+
|
||||
*
|
||||
* Doesn't support:
|
||||
* - MediaSource for Safari iOS
|
||||
* - Customized built-in elements (extends HTMLVideoElement) because Safari
|
||||
* - Autoplay for WebRTC in Safari
|
||||
*/
|
||||
export class VideoRTC extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.DISCONNECT_TIMEOUT = 5000;
|
||||
this.RECONNECT_TIMEOUT = 15000;
|
||||
|
||||
this.CODECS = [
|
||||
'avc1.640029', // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
|
||||
'avc1.64002A', // H.264 high 4.2 (Chromecast 3rd Gen)
|
||||
'avc1.640033', // H.264 high 5.1 (Chromecast with Google TV)
|
||||
'hvc1.1.6.L153.B0', // H.265 main 5.1 (Chromecast Ultra)
|
||||
'mp4a.40.2', // AAC LC
|
||||
'mp4a.40.5', // AAC HE
|
||||
'flac', // FLAC (PCM compatible)
|
||||
'opus', // OPUS Chrome, Firefox
|
||||
];
|
||||
|
||||
/**
|
||||
* [config] Supported modes (webrtc, webrtc/tcp, mse, hls, mp4, mjpeg).
|
||||
* @type {string}
|
||||
*/
|
||||
this.mode = 'webrtc,mse,hls,mjpeg';
|
||||
|
||||
/**
|
||||
* [Config] Requested medias (video, audio, microphone).
|
||||
* @type {string}
|
||||
*/
|
||||
this.media = 'video,audio';
|
||||
|
||||
/**
|
||||
* [config] Run stream when not displayed on the screen. Default `false`.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.background = false;
|
||||
|
||||
/**
|
||||
* [config] Run stream only when player in the viewport. Stop when user scroll out player.
|
||||
* Value is percentage of visibility from `0` (not visible) to `1` (full visible).
|
||||
* Default `0` - disable;
|
||||
* @type {number}
|
||||
*/
|
||||
this.visibilityThreshold = 0;
|
||||
|
||||
/**
|
||||
* [config] Run stream only when browser page on the screen. Stop when user change browser
|
||||
* tab or minimise browser windows.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.visibilityCheck = true;
|
||||
|
||||
/**
|
||||
* [config] WebRTC configuration
|
||||
* @type {RTCConfiguration}
|
||||
*/
|
||||
this.pcConfig = {
|
||||
bundlePolicy: 'max-bundle',
|
||||
iceServers: [{urls: 'stun:stun.l.google.com:19302'}],
|
||||
sdpSemantics: 'unified-plan', // important for Chromecast 1
|
||||
};
|
||||
|
||||
/**
|
||||
* [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED
|
||||
* @type {number}
|
||||
*/
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
|
||||
/**
|
||||
* [info] WebRTC connection state.
|
||||
* @type {number}
|
||||
*/
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
|
||||
/**
|
||||
* @type {HTMLVideoElement}
|
||||
*/
|
||||
this.video = null;
|
||||
|
||||
/**
|
||||
* @type {WebSocket}
|
||||
*/
|
||||
this.ws = null;
|
||||
|
||||
/**
|
||||
* @type {string|URL}
|
||||
*/
|
||||
this.wsURL = '';
|
||||
|
||||
/**
|
||||
* @type {RTCPeerConnection}
|
||||
*/
|
||||
this.pc = null;
|
||||
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
this.connectTS = 0;
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
this.mseCodecs = '';
|
||||
|
||||
/**
|
||||
* [internal] Disconnect TimeoutID.
|
||||
* @type {number}
|
||||
*/
|
||||
this.disconnectTID = 0;
|
||||
|
||||
/**
|
||||
* [internal] Reconnect TimeoutID.
|
||||
* @type {number}
|
||||
*/
|
||||
this.reconnectTID = 0;
|
||||
|
||||
/**
|
||||
* [internal] Handler for receiving Binary from WebSocket.
|
||||
* @type {Function}
|
||||
*/
|
||||
this.ondata = null;
|
||||
|
||||
/**
|
||||
* [internal] Handlers list for receiving JSON from WebSocket.
|
||||
* @type {Object.<string,Function>}
|
||||
*/
|
||||
this.onmessage = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set video source (WebSocket URL). Support relative path.
|
||||
* @param {string|URL} value
|
||||
*/
|
||||
set src(value) {
|
||||
if (typeof value !== 'string') value = value.toString();
|
||||
if (value.startsWith('http')) {
|
||||
value = 'ws' + value.substring(4);
|
||||
} else if (value.startsWith('/')) {
|
||||
value = 'ws' + location.origin.substring(4) + value;
|
||||
}
|
||||
|
||||
this.wsURL = value;
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Play video. Support automute when autoplay blocked.
|
||||
* https://developer.chrome.com/blog/autoplay/
|
||||
*/
|
||||
play() {
|
||||
this.video.play().catch(() => {
|
||||
if (!this.video.muted) {
|
||||
this.video.muted = true;
|
||||
this.video.play().catch(er => {
|
||||
console.warn(er);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to server via WebSocket
|
||||
* @param {Object} value
|
||||
*/
|
||||
send(value) {
|
||||
if (this.ws) this.ws.send(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/** @param {Function} isSupported */
|
||||
codecs(isSupported) {
|
||||
return this.CODECS
|
||||
.filter(codec => this.media.indexOf(codec.indexOf('vc1') > 0 ? 'video' : 'audio') >= 0)
|
||||
.filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join();
|
||||
}
|
||||
|
||||
/**
|
||||
* `CustomElement`. Invoked each time the custom element is appended into a
|
||||
* document-connected element.
|
||||
*/
|
||||
connectedCallback() {
|
||||
if (this.disconnectTID) {
|
||||
clearTimeout(this.disconnectTID);
|
||||
this.disconnectTID = 0;
|
||||
}
|
||||
|
||||
// because video autopause on disconnected from DOM
|
||||
if (this.video) {
|
||||
const seek = this.video.seekable;
|
||||
if (seek.length > 0) {
|
||||
this.video.currentTime = seek.end(seek.length - 1);
|
||||
}
|
||||
this.play();
|
||||
} else {
|
||||
this.oninit();
|
||||
}
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* `CustomElement`. Invoked each time the custom element is disconnected from the
|
||||
* document's DOM.
|
||||
*/
|
||||
disconnectedCallback() {
|
||||
if (this.background || this.disconnectTID) return;
|
||||
if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
|
||||
|
||||
this.disconnectTID = setTimeout(() => {
|
||||
if (this.reconnectTID) {
|
||||
clearTimeout(this.reconnectTID);
|
||||
this.reconnectTID = 0;
|
||||
}
|
||||
|
||||
this.disconnectTID = 0;
|
||||
|
||||
this.ondisconnect();
|
||||
}, this.DISCONNECT_TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates child DOM elements. Called automatically once on `connectedCallback`.
|
||||
*/
|
||||
oninit() {
|
||||
this.video = document.createElement('video');
|
||||
this.video.controls = true;
|
||||
this.video.playsInline = true;
|
||||
this.video.preload = 'auto';
|
||||
|
||||
this.video.style.display = 'block'; // fix bottom margin 4px
|
||||
this.video.style.width = '100%';
|
||||
this.video.style.height = '100%';
|
||||
|
||||
this.appendChild(this.video);
|
||||
|
||||
this.video.addEventListener('error', ev => {
|
||||
console.warn(ev);
|
||||
if (this.ws) this.ws.close(); // run reconnect for broken MSE stream
|
||||
});
|
||||
|
||||
// all Safari lies about supported audio codecs
|
||||
const m = window.navigator.userAgent.match(/Version\/(\d+).+Safari/);
|
||||
if (m) {
|
||||
// AAC from v13, FLAC from v14, OPUS - unsupported
|
||||
const skip = m[1] < '13' ? 'mp4a.40.2' : m[1] < '14' ? 'flac' : 'opus';
|
||||
this.CODECS.splice(this.CODECS.indexOf(skip));
|
||||
}
|
||||
|
||||
if (this.background) return;
|
||||
|
||||
if ('hidden' in document && this.visibilityCheck) {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.disconnectedCallback();
|
||||
} else if (this.isConnected) {
|
||||
this.connectedCallback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ('IntersectionObserver' in window && this.visibilityThreshold) {
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
if (!entry.isIntersecting) {
|
||||
this.disconnectedCallback();
|
||||
} else if (this.isConnected) {
|
||||
this.connectedCallback();
|
||||
}
|
||||
});
|
||||
}, {threshold: this.visibilityThreshold});
|
||||
observer.observe(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket. Called automatically on `connectedCallback`.
|
||||
* @return {boolean} true if the connection has started.
|
||||
*/
|
||||
onconnect() {
|
||||
if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false;
|
||||
|
||||
// CLOSED or CONNECTING => CONNECTING
|
||||
this.wsState = WebSocket.CONNECTING;
|
||||
|
||||
this.connectTS = Date.now();
|
||||
|
||||
this.ws = new WebSocket(this.wsURL);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
this.ws.addEventListener('open', () => this.onopen());
|
||||
this.ws.addEventListener('close', () => this.onclose());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
ondisconnect() {
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
if (this.pc) {
|
||||
this.pc.getSenders().forEach(sender => {
|
||||
if (sender.track) sender.track.stop();
|
||||
});
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
|
||||
this.video.src = '';
|
||||
this.video.srcObject = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array.<string>} of modes (mse, webrtc, etc.)
|
||||
*/
|
||||
onopen() {
|
||||
// CONNECTING => OPEN
|
||||
this.wsState = WebSocket.OPEN;
|
||||
|
||||
this.ws.addEventListener('message', ev => {
|
||||
if (typeof ev.data === 'string') {
|
||||
const msg = JSON.parse(ev.data);
|
||||
for (const mode in this.onmessage) {
|
||||
this.onmessage[mode](msg);
|
||||
}
|
||||
} else {
|
||||
this.ondata(ev.data);
|
||||
}
|
||||
});
|
||||
|
||||
this.ondata = null;
|
||||
this.onmessage = {};
|
||||
|
||||
const modes = [];
|
||||
|
||||
if (this.mode.indexOf('mse') >= 0 && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
|
||||
modes.push('mse');
|
||||
this.onmse();
|
||||
} else if (this.mode.indexOf('hls') >= 0 && this.video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
modes.push('hls');
|
||||
this.onhls();
|
||||
} else if (this.mode.indexOf('mp4') >= 0) {
|
||||
modes.push('mp4');
|
||||
this.onmp4();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) {
|
||||
modes.push('webrtc');
|
||||
this.onwebrtc();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf('mjpeg') >= 0) {
|
||||
if (modes.length) {
|
||||
this.onmessage['mjpeg'] = msg => {
|
||||
if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return;
|
||||
this.onmjpeg();
|
||||
};
|
||||
} else {
|
||||
modes.push('mjpeg');
|
||||
this.onmjpeg();
|
||||
}
|
||||
}
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} true if reconnection has started.
|
||||
*/
|
||||
onclose() {
|
||||
if (this.wsState === WebSocket.CLOSED) return false;
|
||||
|
||||
// CONNECTING, OPEN => CONNECTING
|
||||
this.wsState = WebSocket.CONNECTING;
|
||||
this.ws = null;
|
||||
|
||||
// reconnect no more than once every X seconds
|
||||
const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
|
||||
|
||||
this.reconnectTID = setTimeout(() => {
|
||||
this.reconnectTID = 0;
|
||||
this.onconnect();
|
||||
}, delay);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onmse() {
|
||||
/** @type {MediaSource} */
|
||||
let ms;
|
||||
|
||||
if ('ManagedMediaSource' in window) {
|
||||
const MediaSource = window.ManagedMediaSource;
|
||||
|
||||
ms = new MediaSource();
|
||||
ms.addEventListener('sourceopen', () => {
|
||||
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
|
||||
}, {once: true});
|
||||
|
||||
this.video.disableRemotePlayback = true;
|
||||
this.video.srcObject = ms;
|
||||
} else {
|
||||
ms = new MediaSource();
|
||||
ms.addEventListener('sourceopen', () => {
|
||||
URL.revokeObjectURL(this.video.src);
|
||||
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
|
||||
}, {once: true});
|
||||
|
||||
this.video.src = URL.createObjectURL(ms);
|
||||
this.video.srcObject = null;
|
||||
}
|
||||
|
||||
this.play();
|
||||
|
||||
this.mseCodecs = '';
|
||||
|
||||
this.onmessage['mse'] = msg => {
|
||||
if (msg.type !== 'mse') return;
|
||||
|
||||
this.mseCodecs = msg.value;
|
||||
|
||||
const sb = ms.addSourceBuffer(msg.value);
|
||||
sb.mode = 'segments'; // segments or sequence
|
||||
sb.addEventListener('updateend', () => {
|
||||
if (!sb.updating && bufLen > 0) {
|
||||
try {
|
||||
const data = buf.slice(0, bufLen);
|
||||
sb.appendBuffer(data);
|
||||
bufLen = 0;
|
||||
} catch (e) {
|
||||
// console.debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sb.updating && sb.buffered && sb.buffered.length) {
|
||||
const end = sb.buffered.end(sb.buffered.length - 1);
|
||||
const start = end - 5;
|
||||
const start0 = sb.buffered.start(0);
|
||||
if (start > start0) {
|
||||
sb.remove(start0, start);
|
||||
ms.setLiveSeekableRange(start, end);
|
||||
}
|
||||
if (this.video.currentTime < start) {
|
||||
this.video.currentTime = start;
|
||||
}
|
||||
const gap = end - this.video.currentTime;
|
||||
this.video.playbackRate = gap > 0.1 ? gap : 0.1;
|
||||
// console.debug('VideoRTC.buffered', gap, this.video.playbackRate, this.video.readyState);
|
||||
}
|
||||
});
|
||||
|
||||
const buf = new Uint8Array(2 * 1024 * 1024);
|
||||
let bufLen = 0;
|
||||
|
||||
this.ondata = data => {
|
||||
if (sb.updating || bufLen > 0) {
|
||||
const b = new Uint8Array(data);
|
||||
buf.set(b, bufLen);
|
||||
bufLen += b.byteLength;
|
||||
// console.debug('VideoRTC.buffer', b.byteLength, bufLen);
|
||||
} else {
|
||||
try {
|
||||
sb.appendBuffer(data);
|
||||
} catch (e) {
|
||||
// console.debug(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
onwebrtc() {
|
||||
const pc = new RTCPeerConnection(this.pcConfig);
|
||||
|
||||
pc.addEventListener('icecandidate', ev => {
|
||||
if (ev.candidate && this.mode.indexOf('webrtc/tcp') >= 0 && ev.candidate.protocol === 'udp') return;
|
||||
|
||||
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
|
||||
this.send({type: 'webrtc/candidate', value: candidate});
|
||||
});
|
||||
|
||||
pc.addEventListener('connectionstatechange', () => {
|
||||
if (pc.connectionState === 'connected') {
|
||||
const tracks = pc.getTransceivers()
|
||||
.filter(tr => tr.currentDirection === 'recvonly') // skip inactive
|
||||
.map(tr => tr.receiver.track);
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = document.createElement('video');
|
||||
video2.addEventListener('loadeddata', () => this.onpcvideo(video2), {once: true});
|
||||
video2.srcObject = new MediaStream(tracks);
|
||||
} else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
|
||||
pc.close(); // stop next events
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
this.pc = null;
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.onmessage['webrtc'] = msg => {
|
||||
switch (msg.type) {
|
||||
case 'webrtc/candidate':
|
||||
if (this.mode.indexOf('webrtc/tcp') >= 0 && msg.value.indexOf(' udp ') > 0) return;
|
||||
|
||||
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => {
|
||||
console.warn(er);
|
||||
});
|
||||
break;
|
||||
case 'webrtc/answer':
|
||||
pc.setRemoteDescription({type: 'answer', sdp: msg.value}).catch(er => {
|
||||
console.warn(er);
|
||||
});
|
||||
break;
|
||||
case 'error':
|
||||
if (msg.value.indexOf('webrtc/offer') < 0) return;
|
||||
pc.close();
|
||||
}
|
||||
};
|
||||
|
||||
this.createOffer(pc).then(offer => {
|
||||
this.send({type: 'webrtc/offer', value: offer.sdp});
|
||||
});
|
||||
|
||||
this.pcState = WebSocket.CONNECTING;
|
||||
this.pc = pc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param pc {RTCPeerConnection}
|
||||
* @return {Promise<RTCSessionDescriptionInit>}
|
||||
*/
|
||||
async createOffer(pc) {
|
||||
try {
|
||||
if (this.media.indexOf('microphone') >= 0) {
|
||||
const media = await navigator.mediaDevices.getUserMedia({audio: true});
|
||||
media.getTracks().forEach(track => {
|
||||
pc.addTransceiver(track, {direction: 'sendonly'});
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
|
||||
for (const kind of ['video', 'audio']) {
|
||||
if (this.media.indexOf(kind) >= 0) {
|
||||
pc.addTransceiver(kind, {direction: 'recvonly'});
|
||||
}
|
||||
}
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
return offer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param video2 {HTMLVideoElement}
|
||||
*/
|
||||
onpcvideo(video2) {
|
||||
if (this.pc) {
|
||||
// Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
|
||||
let rtcPriority = 0, msePriority = 0;
|
||||
|
||||
/** @type {MediaStream} */
|
||||
const stream = video2.srcObject;
|
||||
if (stream.getVideoTracks().length > 0) rtcPriority += 0x220;
|
||||
if (stream.getAudioTracks().length > 0) rtcPriority += 0x102;
|
||||
|
||||
if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230;
|
||||
if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210;
|
||||
if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101;
|
||||
|
||||
if (rtcPriority >= msePriority) {
|
||||
this.video.srcObject = stream;
|
||||
this.play();
|
||||
|
||||
this.pcState = WebSocket.OPEN;
|
||||
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
} else {
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
if (this.pc) {
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
video2.srcObject = null;
|
||||
}
|
||||
|
||||
onmjpeg() {
|
||||
this.ondata = data => {
|
||||
this.video.controls = false;
|
||||
this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data);
|
||||
};
|
||||
|
||||
this.send({type: 'mjpeg'});
|
||||
}
|
||||
|
||||
onhls() {
|
||||
this.onmessage['hls'] = msg => {
|
||||
if (msg.type !== 'hls') return;
|
||||
|
||||
const url = 'http' + this.wsURL.substring(2, this.wsURL.indexOf('/ws')) + '/hls/';
|
||||
const playlist = msg.value.replace('hls/', url);
|
||||
this.video.src = 'data:application/vnd.apple.mpegurl;base64,' + btoa(playlist);
|
||||
this.play();
|
||||
};
|
||||
|
||||
this.send({type: 'hls', value: this.codecs(type => this.video.canPlayType(type))});
|
||||
}
|
||||
|
||||
onmp4() {
|
||||
/** @type {HTMLCanvasElement} **/
|
||||
const canvas = document.createElement('canvas');
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
let context;
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = document.createElement('video');
|
||||
video2.autoplay = true;
|
||||
video2.playsInline = true;
|
||||
video2.muted = true;
|
||||
|
||||
video2.addEventListener('loadeddata', () => {
|
||||
if (!context) {
|
||||
canvas.width = video2.videoWidth;
|
||||
canvas.height = video2.videoHeight;
|
||||
context = canvas.getContext('2d');
|
||||
}
|
||||
|
||||
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
this.video.controls = false;
|
||||
this.video.poster = canvas.toDataURL('image/jpeg');
|
||||
});
|
||||
|
||||
this.ondata = data => {
|
||||
video2.src = 'data:video/mp4;base64,' + VideoRTC.btoa(data);
|
||||
};
|
||||
|
||||
this.send({type: 'mp4', value: this.codecs(this.video.canPlayType)});
|
||||
}
|
||||
|
||||
static btoa(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const len = bytes.byteLength;
|
||||
let binary = '';
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
/** Chrome 63+, Safari 11.1+ */
|
||||
import {VideoRTC} from './video-rtc.js?v=1.9.9';
|
||||
import {DigitalPTZ} from './digital-ptz.js?v=3.3.0';
|
||||
|
||||
class WebRTCCamera extends VideoRTC {
|
||||
/**
|
||||
* Step 1. Called by the Hass, when config changed.
|
||||
* @param {Object} config
|
||||
*/
|
||||
setConfig(config) {
|
||||
if (!config.url && !config.entity && !config.streams) throw new Error('Missing `url` or `entity` or `streams`');
|
||||
|
||||
if (config.background) this.background = config.background;
|
||||
|
||||
if (config.intersection === 0) this.visibilityThreshold = 0;
|
||||
else this.visibilityThreshold = config.intersection || 0.75;
|
||||
|
||||
/**
|
||||
* @type {{
|
||||
* url: string,
|
||||
* entity: string,
|
||||
* mode: string,
|
||||
* media: string,
|
||||
*
|
||||
* streams: Array<{
|
||||
* name: string,
|
||||
* url: string,
|
||||
* entity: string,
|
||||
* mode: string,
|
||||
* media: string,
|
||||
* }>,
|
||||
*
|
||||
* title: string,
|
||||
* poster: string,
|
||||
* poster_remote: boolean,
|
||||
* muted: boolean,
|
||||
* intersection: number,
|
||||
* ui: boolean,
|
||||
* style: string,
|
||||
* background: boolean,
|
||||
*
|
||||
* server: string,
|
||||
*
|
||||
* mse: boolean,
|
||||
* webrtc: boolean,
|
||||
*
|
||||
* digital_ptz:{
|
||||
* mouse_drag_pan: boolean,
|
||||
* mouse_wheel_zoom: boolean,
|
||||
* mouse_double_click_zoom: boolean,
|
||||
* touch_pinch_zoom: boolean,
|
||||
* touch_drag_pan: boolean,
|
||||
* touch_tap_drag_zoom: boolean,
|
||||
* persist: boolean|string,
|
||||
* },
|
||||
* ptz:{
|
||||
* opacity: number|string,
|
||||
* service: string,
|
||||
* data_left, data_up, data_right, data_down, data_zoom_in, data_zoom_out, data_home
|
||||
* },
|
||||
* shortcuts:Array<{ name:string, icon:string }>,
|
||||
* }} config
|
||||
*/
|
||||
this.config = Object.assign({
|
||||
mode: config.mse === false ? 'webrtc' : config.webrtc === false ? 'mse' : this.mode,
|
||||
media: this.media,
|
||||
streams: [{url: config.url, entity: config.entity}],
|
||||
poster_remote: config.poster && (config.poster.indexOf('://') > 0 || config.poster.charAt(0) === '/'),
|
||||
}, config);
|
||||
|
||||
this.streamID = -1;
|
||||
this.nextStream(false);
|
||||
|
||||
this.onhass = [];
|
||||
}
|
||||
|
||||
set hass(hass) {
|
||||
this._hass = hass;
|
||||
this.onhass.forEach(fn => fn());
|
||||
// if card in vertical stack - `hass` property assign after `onconnect`
|
||||
// this.onconnect();
|
||||
}
|
||||
|
||||
get hass() {
|
||||
return this._hass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the Hass to calculate default card height.
|
||||
*/
|
||||
getCardSize() {
|
||||
return 5; // x 50px
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the Hass to get defaul card config
|
||||
* @return {{url: string}}
|
||||
*/
|
||||
static getStubConfig() {
|
||||
return {'url': ''};
|
||||
}
|
||||
|
||||
setStatus(mode, status) {
|
||||
const divMode = this.querySelector('.mode').innerText;
|
||||
if (mode === 'error' && divMode !== 'Loading..' && divMode !== 'Loading...') return;
|
||||
|
||||
this.querySelector('.mode').innerText = mode;
|
||||
this.querySelector('.status').innerText = status || '';
|
||||
}
|
||||
|
||||
/** @param reload {boolean} */
|
||||
nextStream(reload) {
|
||||
this.streamID = (this.streamID + 1) % this.config.streams.length;
|
||||
|
||||
const stream = this.config.streams[this.streamID];
|
||||
this.config.url = stream.url;
|
||||
this.config.entity = stream.entity;
|
||||
this.mode = stream.mode || this.config.mode;
|
||||
this.media = stream.media || this.config.media;
|
||||
|
||||
if (reload) {
|
||||
this.ondisconnect();
|
||||
setTimeout(() => this.onconnect(), 100); // wait ws.close event
|
||||
}
|
||||
}
|
||||
|
||||
/** @return {string} */
|
||||
get streamName() {
|
||||
return this.config.streams[this.streamID].name || `S${this.streamID}`;
|
||||
}
|
||||
|
||||
oninit() {
|
||||
super.oninit();
|
||||
this.renderMain();
|
||||
this.renderDigitalPTZ();
|
||||
this.renderPTZ();
|
||||
this.renderCustomUI();
|
||||
this.renderShortcuts();
|
||||
this.renderStyle();
|
||||
}
|
||||
|
||||
onconnect() {
|
||||
if (!this.config || !this.hass) return false;
|
||||
if (!this.isConnected || this.ws || this.pc) return false;
|
||||
|
||||
const divMode = this.querySelector('.mode').innerText;
|
||||
if (divMode === 'Loading..') return;
|
||||
|
||||
this.setStatus('Loading..');
|
||||
|
||||
this.hass.callWS({
|
||||
type: 'auth/sign_path', path: '/api/webrtc/ws'
|
||||
}).then(data => {
|
||||
if (this.config.poster && !this.config.poster_remote) {
|
||||
this.video.poster = this.hass.hassUrl(data.path) + '&poster=' + encodeURIComponent(this.config.poster);
|
||||
}
|
||||
|
||||
this.wsURL = 'ws' + this.hass.hassUrl(data.path).substring(4);
|
||||
|
||||
if (this.config.entity) {
|
||||
this.wsURL += '&entity=' + this.config.entity;
|
||||
} else if (this.config.url) {
|
||||
this.wsURL += '&url=' + encodeURIComponent(this.config.url);
|
||||
} else {
|
||||
this.setStatus('IMG');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.server) {
|
||||
this.wsURL += '&server=' + encodeURIComponent(this.config.server);
|
||||
}
|
||||
|
||||
if (super.onconnect()) {
|
||||
this.setStatus('Loading...');
|
||||
} else {
|
||||
this.setStatus('error', 'unable to connect');
|
||||
}
|
||||
}).catch(er => {
|
||||
this.setStatus('error', er);
|
||||
});
|
||||
}
|
||||
|
||||
onopen() {
|
||||
const result = super.onopen();
|
||||
|
||||
this.onmessage['stream'] = msg => {
|
||||
switch (msg.type) {
|
||||
case 'error':
|
||||
this.setStatus('error', msg.value);
|
||||
break;
|
||||
case 'mse':
|
||||
case 'hls':
|
||||
case 'mp4':
|
||||
case 'mjpeg':
|
||||
this.setStatus(msg.type.toUpperCase(), this.config.title || '');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
onpcvideo(ev) {
|
||||
super.onpcvideo(ev);
|
||||
|
||||
if (this.pcState !== WebSocket.CLOSED) {
|
||||
this.setStatus('RTC', this.config.title || '');
|
||||
}
|
||||
}
|
||||
|
||||
renderMain() {
|
||||
const shadow = this.attachShadow({mode: 'open'});
|
||||
shadow.innerHTML = `
|
||||
<style>
|
||||
ha-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
ha-icon {
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
.player {
|
||||
background-color: black;
|
||||
height: 100%;
|
||||
position: relative; /* important for Safari */
|
||||
}
|
||||
.player:active {
|
||||
cursor: move; /* important for zoom-controller */
|
||||
}
|
||||
.player .ptz-transform {
|
||||
height: 100%;
|
||||
}
|
||||
.header {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mode {
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
<ha-card class="card">
|
||||
<div class="player">
|
||||
<div class="ptz-transform"></div>
|
||||
</div>
|
||||
<div class="header">
|
||||
<div class="status"></div>
|
||||
<div class="mode"></div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
|
||||
this.querySelector = selectors => this.shadowRoot.querySelector(selectors);
|
||||
this.querySelector('.ptz-transform').appendChild(this.video);
|
||||
|
||||
const mode = this.querySelector('.mode');
|
||||
mode.addEventListener('click', () => this.nextStream(true));
|
||||
|
||||
if (this.config.muted) this.video.muted = true;
|
||||
if (this.config.poster_remote) this.video.poster = this.config.poster;
|
||||
}
|
||||
|
||||
renderDigitalPTZ() {
|
||||
if (this.config.digital_ptz === false) return;
|
||||
new DigitalPTZ(
|
||||
this.querySelector('.player'),
|
||||
this.querySelector('.player .ptz-transform'),
|
||||
this.video,
|
||||
Object.assign({}, this.config.digital_ptz, {persist_key: this.config.url})
|
||||
);
|
||||
}
|
||||
|
||||
renderPTZ() {
|
||||
if (!this.config.ptz || !this.config.ptz.service) return;
|
||||
|
||||
let hasMove = false;
|
||||
let hasZoom = false;
|
||||
let hasHome = false;
|
||||
for (const prefix of ['', '_start', '_end', '_long']) {
|
||||
hasMove = hasMove || this.config.ptz['data' + prefix + '_right'];
|
||||
hasMove = hasMove || this.config.ptz['data' + prefix + '_left'];
|
||||
hasMove = hasMove || this.config.ptz['data' + prefix + '_up'];
|
||||
hasMove = hasMove || this.config.ptz['data' + prefix + '_down'];
|
||||
|
||||
hasZoom = hasZoom || this.config.ptz['data' + prefix + '_zoom_in'];
|
||||
hasZoom = hasZoom || this.config.ptz['data' + prefix + '_zoom_out'];
|
||||
|
||||
hasHome = hasHome || this.config.ptz['data' + prefix + '_home'];
|
||||
}
|
||||
|
||||
const card = this.querySelector('.card');
|
||||
card.insertAdjacentHTML('beforebegin', `
|
||||
<style>
|
||||
.ptz {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
transition: opacity .3s ease-in-out;
|
||||
opacity: ${parseFloat(this.config.ptz.opacity) || 0.4};
|
||||
}
|
||||
.ptz:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.ptz-move {
|
||||
position: relative;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 50%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: ${hasMove ? 'block' : 'none'};
|
||||
}
|
||||
.ptz-zoom {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 40px;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
display: ${hasZoom ? 'block' : 'none'};
|
||||
}
|
||||
.ptz-home {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
align-self: center;
|
||||
display: ${hasHome ? 'block' : 'none'};
|
||||
}
|
||||
.up {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.down {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.left {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.right {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.zoom_out {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.zoom_in {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.home {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
</style>
|
||||
`);
|
||||
card.insertAdjacentHTML('beforeend', `
|
||||
<div class="ptz">
|
||||
<div class="ptz-move">
|
||||
<ha-icon class="right" icon="mdi:arrow-right"></ha-icon>
|
||||
<ha-icon class="left" icon="mdi:arrow-left"></ha-icon>
|
||||
<ha-icon class="up" icon="mdi:arrow-up"></ha-icon>
|
||||
<ha-icon class="down" icon="mdi:arrow-down"></ha-icon>
|
||||
</div>
|
||||
<div class="ptz-zoom">
|
||||
<ha-icon class="zoom_in" icon="mdi:plus"></ha-icon>
|
||||
<ha-icon class="zoom_out" icon="mdi:minus"></ha-icon>
|
||||
</div>
|
||||
<div class="ptz-home">
|
||||
<ha-icon class="home" icon="mdi:home"></ha-icon>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const template = JSON.stringify(this.config.ptz);
|
||||
const handle = path => {
|
||||
if (!this.config.ptz['data_' + path]) return;
|
||||
const config = template.indexOf('${') < 0 ? this.config.ptz : JSON.parse(eval('`' + template + '`'));
|
||||
const [domain, service] = config.service.split('.', 2);
|
||||
const data = config['data_' + path];
|
||||
this.hass.callService(domain, service, data);
|
||||
};
|
||||
const ptz = this.querySelector('.ptz');
|
||||
for (const [start, end] of [['touchstart', 'touchend'], ['mousedown', 'mouseup']]) {
|
||||
ptz.addEventListener(start, startEvt => {
|
||||
const {className} = startEvt.target;
|
||||
startEvt.preventDefault();
|
||||
handle('start_' + className);
|
||||
window.addEventListener(end, endEvt => {
|
||||
endEvt.preventDefault();
|
||||
handle('end_' + className);
|
||||
if (endEvt.timeStamp - startEvt.timeStamp > 400) {
|
||||
handle('long_' + className);
|
||||
} else {
|
||||
handle(className);
|
||||
}
|
||||
}, {once: true});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
saveScreenshot() {
|
||||
const a = document.createElement('a');
|
||||
|
||||
if (this.video.videoWidth && this.video.videoHeight) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = this.video.videoWidth;
|
||||
canvas.height = this.video.videoHeight;
|
||||
canvas.getContext('2d').drawImage(this.video, 0, 0, canvas.width, canvas.height);
|
||||
a.href = canvas.toDataURL('image/jpeg');
|
||||
} else if (this.video.poster && this.video.poster.startsWith('data:image/jpeg')) {
|
||||
a.href = this.video.poster;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const ts = new Date().toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '');
|
||||
a.download = `snapshot_${ts}.jpeg`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
renderCustomUI() {
|
||||
if (!this.config.ui) return;
|
||||
|
||||
this.video.controls = false;
|
||||
this.video.style.pointerEvents = 'none';
|
||||
|
||||
const card = this.querySelector('.card');
|
||||
card.insertAdjacentHTML('beforebegin', `
|
||||
<style>
|
||||
.spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.controls {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
display: flex;
|
||||
}
|
||||
.space {
|
||||
width: 100%;
|
||||
}
|
||||
.volume {
|
||||
display: none;
|
||||
}
|
||||
.stream {
|
||||
padding-top: 2px;
|
||||
margin-left: 2px;
|
||||
font-weight: 400;
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
display: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
`);
|
||||
card.insertAdjacentHTML('beforeend', `
|
||||
<div class="ui">
|
||||
<ha-circular-progress class="spinner"></ha-circular-progress>
|
||||
<div class="controls">
|
||||
<ha-icon class="fullscreen" icon="mdi:fullscreen"></ha-icon>
|
||||
<ha-icon class="screenshot" icon="mdi:floppy"></ha-icon>
|
||||
<ha-icon class="pictureinpicture" icon="mdi:picture-in-picture-bottom-right"></ha-icon>
|
||||
<span class="stream">${this.streamName}</span>
|
||||
<span class="space"></span>
|
||||
<ha-icon class="play" icon="mdi:play"></ha-icon>
|
||||
<ha-icon class="volume" icon="mdi:volume-high"></ha-icon>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const video = this.video;
|
||||
|
||||
const fullscreen = this.querySelector('.fullscreen');
|
||||
if (this.requestFullscreen) {
|
||||
this.addEventListener('fullscreenchange', () => {
|
||||
fullscreen.icon = document.fullscreenElement ? 'mdi:fullscreen-exit' : 'mdi:fullscreen';
|
||||
});
|
||||
} else if (video.webkitEnterFullscreen) {
|
||||
this.requestFullscreen = () => new Promise((resolve, reject) => {
|
||||
try {
|
||||
video.webkitEnterFullscreen();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
video.addEventListener('webkitendfullscreen', () => {
|
||||
setTimeout(() => this.play(), 1000); // fix bug in iOS
|
||||
});
|
||||
} else {
|
||||
fullscreen.style.display = 'none';
|
||||
}
|
||||
|
||||
const pip = this.querySelector('.pictureinpicture');
|
||||
if (video.requestPictureInPicture) {
|
||||
video.addEventListener('enterpictureinpicture', () => {
|
||||
pip.icon = 'mdi:rectangle';
|
||||
this.background = true;
|
||||
});
|
||||
video.addEventListener('leavepictureinpicture', () => {
|
||||
pip.icon = 'mdi:picture-in-picture-bottom-right';
|
||||
this.background = this.config.background;
|
||||
this.play();
|
||||
});
|
||||
} else {
|
||||
pip.style.display = 'none';
|
||||
}
|
||||
|
||||
const ui = this.querySelector('.ui');
|
||||
ui.addEventListener('click', ev => {
|
||||
const icon = ev.target.icon;
|
||||
if (icon === 'mdi:play') {
|
||||
this.play();
|
||||
} else if (icon === 'mdi:volume-mute') {
|
||||
video.muted = false;
|
||||
} else if (icon === 'mdi:volume-high') {
|
||||
video.muted = true;
|
||||
} else if (icon === 'mdi:fullscreen') {
|
||||
this.requestFullscreen().catch(console.warn);
|
||||
} else if (icon === 'mdi:fullscreen-exit') {
|
||||
document.exitFullscreen().catch(console.warn);
|
||||
} else if (icon === 'mdi:floppy') {
|
||||
this.saveScreenshot();
|
||||
} else if (icon === 'mdi:picture-in-picture-bottom-right') {
|
||||
video.requestPictureInPicture().catch(console.warn);
|
||||
} else if (icon === 'mdi:rectangle') {
|
||||
document.exitPictureInPicture().catch(console.warn);
|
||||
} else if (ev.target.className === 'stream') {
|
||||
this.nextStream(true);
|
||||
ev.target.innerText = this.streamName;
|
||||
}
|
||||
});
|
||||
|
||||
const spinner = this.querySelector('.spinner');
|
||||
video.addEventListener('waiting', () => {
|
||||
spinner.style.display = 'block';
|
||||
});
|
||||
video.addEventListener('playing', () => {
|
||||
spinner.style.display = 'none';
|
||||
});
|
||||
|
||||
const play = this.querySelector('.play');
|
||||
video.addEventListener('play', () => {
|
||||
play.style.display = 'none';
|
||||
});
|
||||
video.addEventListener('pause', () => {
|
||||
play.style.display = 'block';
|
||||
});
|
||||
|
||||
const volume = this.querySelector('.volume');
|
||||
video.addEventListener('loadeddata', () => {
|
||||
volume.style.display = this.hasAudio ? 'block' : 'none';
|
||||
});
|
||||
video.addEventListener('volumechange', () => {
|
||||
volume.icon = video.muted ? 'mdi:volume-mute' : 'mdi:volume-high';
|
||||
});
|
||||
|
||||
const stream = this.querySelector('.stream');
|
||||
stream.style.display = this.config.streams.length > 1 ? 'block' : 'none';
|
||||
}
|
||||
|
||||
renderShortcuts() {
|
||||
if (!this.config.shortcuts) return;
|
||||
|
||||
const card = this.querySelector('.card');
|
||||
card.insertAdjacentHTML('beforebegin', `
|
||||
<style>
|
||||
.shortcuts {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
}
|
||||
</style>
|
||||
`);
|
||||
card.insertAdjacentHTML('beforeend', '<div class="shortcuts"></div>');
|
||||
|
||||
const shortcuts = this.querySelector('.shortcuts');
|
||||
shortcuts.addEventListener('click', ev => {
|
||||
const value = this.config.shortcuts[ev.target.dataset.index];
|
||||
if (value.more_info !== undefined) {
|
||||
const event = new Event('hass-more-info', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
event.detail = {entityId: value.more_info};
|
||||
ev.target.dispatchEvent(event);
|
||||
}
|
||||
if (value.service !== undefined) {
|
||||
const [domain, name] = value.service.split('.');
|
||||
this.hass.callService(domain, name, value.service_data || {});
|
||||
}
|
||||
});
|
||||
|
||||
this.renderTemplate('shortcuts', () => {
|
||||
shortcuts.innerHTML = this.config.shortcuts.map((value, index) => `
|
||||
<ha-icon data-index="${index}" icon="${value.icon}" title="${value.name}"></ha-icon>
|
||||
`).join('');
|
||||
});
|
||||
}
|
||||
|
||||
renderStyle() {
|
||||
if (!this.config.style) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
const card = this.querySelector('.card');
|
||||
card.insertAdjacentElement('beforebegin', style);
|
||||
|
||||
this.renderTemplate('style', () => {
|
||||
style.innerText = this.config.style;
|
||||
});
|
||||
}
|
||||
|
||||
renderTemplate(name, renderHTML) {
|
||||
const config = this.config[name];
|
||||
// support config param as string or as object
|
||||
const template = typeof config === 'string' ? config : JSON.stringify(config);
|
||||
// check if config param has template
|
||||
if (template.indexOf('${') >= 0) {
|
||||
const render = () => {
|
||||
try {
|
||||
const states = this.hass ? this.hass.states : undefined;
|
||||
this.config[name] = JSON.parse(eval('`' + template + '`'));
|
||||
renderHTML();
|
||||
} catch (e) {
|
||||
console.debug(e);
|
||||
}
|
||||
};
|
||||
this.onhass.push(render);
|
||||
render();
|
||||
} else {
|
||||
renderHTML();
|
||||
}
|
||||
}
|
||||
|
||||
get hasAudio() {
|
||||
return (
|
||||
(this.video.srcObject && this.video.srcObject.getAudioTracks && this.video.srcObject.getAudioTracks().length) ||
|
||||
(this.video.mozHasAudio || this.video.webkitAudioDecodedByteCount) ||
|
||||
(this.video.audioTracks && this.video.audioTracks.length)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('webrtc-camera', WebRTCCamera);
|
||||
|
||||
const card = {
|
||||
type: 'webrtc-camera',
|
||||
name: 'WebRTC Camera',
|
||||
preview: false,
|
||||
description: 'WebRTC camera allows you to view the stream of almost any camera without delay',
|
||||
};
|
||||
// Apple iOS 12 doesn't support `||=`
|
||||
if (window.customCards) window.customCards.push(card);
|
||||
else window.customCards = [card];
|
||||
|
||||
Reference in New Issue
Block a user