Media Browser Panel (#6772)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Zack Arnett 2020-09-04 16:01:20 -05:00 committed by GitHub
parent b065f002a4
commit e63a78bcdb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 802 additions and 318 deletions

View File

@ -3,56 +3,39 @@ import {
CSSResult,
customElement,
html,
internalProperty,
LitElement,
property,
internalProperty,
PropertyValues,
TemplateResult,
} from "lit-element";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateName } from "../common/entity/compute_state_name";
import { supportsFeature } from "../common/entity/supports-feature";
import { nextRender } from "../common/util/render-status";
import { getExternalConfig } from "../external_app/external_config";
import {
CAMERA_SUPPORT_STREAM,
computeMJPEGStreamUrl,
fetchStreamUrl,
} from "../data/camera";
import { CameraEntity, HomeAssistant } from "../types";
type HLSModule = typeof import("hls.js");
import "./ha-hls-player";
@customElement("ha-camera-stream")
class HaCameraStream extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property() public stateObj?: CameraEntity;
@property({ attribute: false }) public stateObj?: CameraEntity;
@property({ type: Boolean }) public showControls = false;
@internalProperty() private _attached = false;
// We keep track if we should force MJPEG with a string
// that way it automatically resets if we change entity.
@internalProperty() private _forceMJPEG: string | undefined = undefined;
@internalProperty() private _forceMJPEG?: string;
private _hlsPolyfillInstance?: Hls;
private _useExoPlayer = false;
public connectedCallback() {
super.connectedCallback();
this._attached = true;
}
public disconnectedCallback() {
super.disconnectedCallback();
this._attached = false;
}
@internalProperty() private _url?: string;
protected render(): TemplateResult {
if (!this.stateObj || !this._attached) {
if (!this.stateObj || (!this._forceMJPEG && !this._url)) {
return html``;
}
@ -70,50 +53,22 @@ class HaCameraStream extends LitElement {
/>
`
: html`
<video
<ha-hls-player
autoplay
muted
playsinline
?controls=${this.showControls}
@loadeddata=${this._elementResized}
></video>
.hass=${this.hass}
.url=${this._url!}
></ha-hls-player>
`}
`;
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
const stateObjChanged = changedProps.has("stateObj");
const attachedChanged = changedProps.has("_attached");
const oldState = changedProps.get("stateObj") as this["stateObj"];
const oldEntityId = oldState ? oldState.entity_id : undefined;
const curEntityId = this.stateObj ? this.stateObj.entity_id : undefined;
if (
(!stateObjChanged && !attachedChanged) ||
(stateObjChanged && oldEntityId === curEntityId)
) {
return;
}
// If we are no longer attached, destroy polyfill.
if (attachedChanged && !this._attached) {
this._destroyPolyfill();
return;
}
// Nothing to do if we are render MJPEG.
if (this._shouldRenderMJPEG) {
return;
}
// Tear down existing polyfill, if available
this._destroyPolyfill();
if (curEntityId) {
this._startHls();
protected updated(changedProps: PropertyValues): void {
if (changedProps.has("stateObj")) {
this._forceMJPEG = undefined;
this._getStreamUrl();
}
}
@ -125,136 +80,35 @@ class HaCameraStream extends LitElement {
);
}
private get _videoEl(): HTMLVideoElement {
return this.shadowRoot!.querySelector("video")!;
}
private async _getUseExoPlayer(): Promise<boolean> {
if (!this.hass!.auth.external) {
return false;
}
const externalConfig = await getExternalConfig(this.hass!.auth.external);
return externalConfig && externalConfig.hasExoPlayer;
}
private async _startHls(): Promise<void> {
// eslint-disable-next-line
let hls;
const videoEl = this._videoEl;
this._useExoPlayer = await this._getUseExoPlayer();
if (!this._useExoPlayer) {
hls = ((await import(/* webpackChunkName: "hls.js" */ "hls.js")) as any)
.default as HLSModule;
let hlsSupported = hls.isSupported();
if (!hlsSupported) {
hlsSupported =
videoEl.canPlayType("application/vnd.apple.mpegurl") !== "";
}
if (!hlsSupported) {
this._forceMJPEG = this.stateObj!.entity_id;
return;
}
}
private async _getStreamUrl(): Promise<void> {
try {
const { url } = await fetchStreamUrl(
this.hass!,
this.stateObj!.entity_id
);
if (this._useExoPlayer) {
this._renderHLSExoPlayer(url);
} else if (hls.isSupported()) {
this._renderHLSPolyfill(videoEl, hls, url);
} else {
this._renderHLSNative(videoEl, url);
}
return;
this._url = url;
} catch (err) {
// Fails if we were unable to get a stream
// eslint-disable-next-line
console.error(err);
this._forceMJPEG = this.stateObj!.entity_id;
}
}
private async _renderHLSExoPlayer(url: string) {
window.addEventListener("resize", this._resizeExoPlayer);
this.updateComplete.then(() => nextRender()).then(this._resizeExoPlayer);
this._videoEl.style.visibility = "hidden";
await this.hass!.auth.external!.sendMessage({
type: "exoplayer/play_hls",
payload: new URL(url, window.location.href).toString(),
});
}
private _resizeExoPlayer = () => {
const rect = this._videoEl.getBoundingClientRect();
this.hass!.auth.external!.fireMessage({
type: "exoplayer/resize",
payload: {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
},
});
};
private async _renderHLSNative(videoEl: HTMLVideoElement, url: string) {
videoEl.src = url;
await new Promise((resolve) =>
videoEl.addEventListener("loadedmetadata", resolve)
);
videoEl.play();
}
private async _renderHLSPolyfill(
videoEl: HTMLVideoElement,
// eslint-disable-next-line
Hls: HLSModule,
url: string
) {
const hls = new Hls({
liveBackBufferLength: 60,
fragLoadingTimeOut: 30000,
manifestLoadingTimeOut: 30000,
levelLoadingTimeOut: 30000,
});
this._hlsPolyfillInstance = hls;
hls.attachMedia(videoEl);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
hls.loadSource(url);
});
}
private _elementResized() {
fireEvent(this, "iron-resize");
}
private _destroyPolyfill() {
if (this._hlsPolyfillInstance) {
this._hlsPolyfillInstance.destroy();
this._hlsPolyfillInstance = undefined;
}
if (this._useExoPlayer) {
window.removeEventListener("resize", this._resizeExoPlayer);
this.hass!.auth.external!.fireMessage({ type: "exoplayer/stop" });
}
}
static get styles(): CSSResult {
return css`
:host,
img,
video {
img {
display: block;
}
img,
video {
img {
width: 100%;
}
`;

View File

@ -10,7 +10,7 @@ import "./ha-icon-button";
const MwcDialog = customElements.get("mwc-dialog") as Constructor<Dialog>;
export const createCloseHeading = (hass: HomeAssistant, title: string) => html`
${title}
<span class="header_title">${title}</span>
<mwc-icon-button
aria-label=${hass.localize("ui.dialogs.generic.close")}
dialogAction="close"
@ -77,10 +77,17 @@ export class HaDialog extends MwcDialog {
text-decoration: none;
color: inherit;
}
.header_title {
margin-right: 40px;
}
[dir="rtl"].header_button {
right: auto;
left: 16px;
}
[dir="rtl"].header_title {
margin-left: 40px;
margin-right: 0px;
}
`,
];
}

View File

@ -0,0 +1,216 @@
import {
css,
CSSResult,
customElement,
html,
internalProperty,
LitElement,
property,
PropertyValues,
query,
TemplateResult,
} from "lit-element";
import { fireEvent } from "../common/dom/fire_event";
import { nextRender } from "../common/util/render-status";
import { getExternalConfig } from "../external_app/external_config";
import type { HomeAssistant } from "../types";
type HLSModule = typeof import("hls.js");
@customElement("ha-hls-player")
class HaHLSPlayer extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public url!: string;
@property({ type: Boolean, attribute: "controls" })
public controls = false;
@property({ type: Boolean, attribute: "muted" })
public muted = false;
@property({ type: Boolean, attribute: "autoplay" })
public autoPlay = false;
@property({ type: Boolean, attribute: "playsinline" })
public playsInline = false;
@query("video") private _videoEl!: HTMLVideoElement;
@internalProperty() private _attached = false;
private _hlsPolyfillInstance?: Hls;
private _useExoPlayer = false;
public connectedCallback() {
super.connectedCallback();
this._attached = true;
}
public disconnectedCallback() {
super.disconnectedCallback();
this._attached = false;
}
protected render(): TemplateResult {
if (!this._attached) {
return html``;
}
return html`
<video
?autoplay=${this.autoPlay}
?muted=${this.muted}
?playsinline=${this.playsInline}
?controls=${this.controls}
@loadeddata=${this._elementResized}
></video>
`;
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
const attachedChanged = changedProps.has("_attached");
const urlChanged = changedProps.has("url");
if (!urlChanged && !attachedChanged) {
return;
}
// If we are no longer attached, destroy polyfill
if (attachedChanged && !this._attached) {
// Tear down existing polyfill, if available
this._destroyPolyfill();
return;
}
this._destroyPolyfill();
this._startHls();
}
private async _getUseExoPlayer(): Promise<boolean> {
if (!this.hass!.auth.external) {
return false;
}
const externalConfig = await getExternalConfig(this.hass!.auth.external);
return externalConfig && externalConfig.hasExoPlayer;
}
private async _startHls(): Promise<void> {
let hls: any;
const videoEl = this._videoEl;
this._useExoPlayer = await this._getUseExoPlayer();
if (!this._useExoPlayer) {
hls = ((await import(/* webpackChunkName: "hls.js" */ "hls.js")) as any)
.default as HLSModule;
let hlsSupported = hls.isSupported();
if (!hlsSupported) {
hlsSupported =
videoEl.canPlayType("application/vnd.apple.mpegurl") !== "";
}
if (!hlsSupported) {
this._videoEl.innerHTML = this.hass.localize(
"ui.components.media-browser.video_not_supported"
);
return;
}
}
const url = this.url;
if (this._useExoPlayer) {
this._renderHLSExoPlayer(url);
} else if (hls.isSupported()) {
this._renderHLSPolyfill(videoEl, hls, url);
} else {
this._renderHLSNative(videoEl, url);
}
}
private async _renderHLSExoPlayer(url: string) {
window.addEventListener("resize", this._resizeExoPlayer);
this.updateComplete.then(() => nextRender()).then(this._resizeExoPlayer);
this._videoEl.style.visibility = "hidden";
await this.hass!.auth.external!.sendMessage({
type: "exoplayer/play_hls",
payload: new URL(url, window.location.href).toString(),
});
}
private _resizeExoPlayer = () => {
const rect = this._videoEl.getBoundingClientRect();
this.hass!.auth.external!.fireMessage({
type: "exoplayer/resize",
payload: {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
},
});
};
private async _renderHLSPolyfill(
videoEl: HTMLVideoElement,
Hls: HLSModule,
url: string
) {
const hls = new Hls({
liveBackBufferLength: 60,
fragLoadingTimeOut: 30000,
manifestLoadingTimeOut: 30000,
levelLoadingTimeOut: 30000,
});
this._hlsPolyfillInstance = hls;
hls.attachMedia(videoEl);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
hls.loadSource(url);
});
}
private async _renderHLSNative(videoEl: HTMLVideoElement, url: string) {
videoEl.src = url;
await new Promise((resolve) =>
videoEl.addEventListener("loadedmetadata", resolve)
);
videoEl.play();
}
private _elementResized() {
fireEvent(this, "iron-resize");
}
private _destroyPolyfill() {
if (this._hlsPolyfillInstance) {
this._hlsPolyfillInstance.destroy();
this._hlsPolyfillInstance = undefined;
}
if (this._useExoPlayer) {
window.removeEventListener("resize", this._resizeExoPlayer);
this.hass!.auth.external!.fireMessage({ type: "exoplayer/stop" });
}
}
static get styles(): CSSResult {
return css`
:host,
video {
display: block;
}
video {
width: 100%;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-hls-player": HaHLSPlayer;
}
}

View File

@ -8,7 +8,7 @@ import {
property,
TemplateResult,
} from "lit-element";
import { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent, HASSDomEvent } from "../../common/dom/fire_event";
import type {
MediaPickedEvent,
MediaPlayerBrowseAction,
@ -33,16 +33,17 @@ class DialogMediaPlayerBrowse extends LitElement {
@internalProperty() private _params?: MediaPlayerBrowseDialogParams;
public async showDialog(
params: MediaPlayerBrowseDialogParams
): Promise<void> {
public showDialog(params: MediaPlayerBrowseDialogParams): void {
this._params = params;
this._entityId = this._params.entityId;
this._mediaContentId = this._params.mediaContentId;
this._mediaContentType = this._params.mediaContentType;
this._action = this._params.action || "play";
}
await this.updateComplete;
public closeDialog() {
this._params = undefined;
fireEvent(this, "dialog-closed", {dialog: this.localName});
}
protected render(): TemplateResult {
@ -57,7 +58,7 @@ class DialogMediaPlayerBrowse extends LitElement {
escapeKeyAction
hideActions
flexContent
@closed=${this._closeDialog}
@closed=${this.closeDialog}
>
<ha-media-player-browse
dialog
@ -66,21 +67,17 @@ class DialogMediaPlayerBrowse extends LitElement {
.action=${this._action!}
.mediaContentId=${this._mediaContentId}
.mediaContentType=${this._mediaContentType}
@close-dialog=${this._closeDialog}
@close-dialog=${this.closeDialog}
@media-picked=${this._mediaPicked}
></ha-media-player-browse>
</ha-dialog>
`;
}
private _closeDialog() {
this._params = undefined;
}
private _mediaPicked(ev: HASSDomEvent<MediaPickedEvent>): void {
this._params!.mediaPickedCallback(ev.detail);
if (this._action !== "play") {
this._closeDialog();
this.closeDialog();
}
}
@ -93,14 +90,6 @@ class DialogMediaPlayerBrowse extends LitElement {
--dialog-content-padding: 0;
}
ha-header-bar {
--mdc-theme-on-primary: var(--primary-text-color);
--mdc-theme-primary: var(--mdc-theme-surface);
flex-shrink: 0;
border-bottom: 1px solid
var(--mdc-dialog-scroll-divider-color, rgba(0, 0, 0, 0.12));
}
@media (min-width: 800px) {
ha-dialog {
--mdc-dialog-max-width: 800px;

View File

@ -22,7 +22,13 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeRTLDirection } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce";
import { browseMediaPlayer, MediaPickedEvent } from "../../data/media-player";
import {
browseLocalMediaPlayer,
browseMediaPlayer,
BROWSER_SOURCE,
MediaPickedEvent,
MediaPlayerBrowseAction,
} from "../../data/media-player";
import type { MediaPlayerItem } from "../../data/media-player";
import { installResizeObserver } from "../../panels/lovelace/common/install-resize-observer";
import { haStyle } from "../../resources/styles";
@ -50,11 +56,7 @@ export class HaMediaPlayerBrowse extends LitElement {
@property() public mediaContentType?: string;
@property() public action: "pick" | "play" = "play";
@property({ type: Boolean }) public hideBack = false;
@property({ type: Boolean }) public hideTitle = false;
@property() public action: MediaPlayerBrowseAction = "play";
@property({ type: Boolean }) public dialog = false;
@ -88,52 +90,53 @@ export class HaMediaPlayerBrowse extends LitElement {
}
protected render(): TemplateResult {
if (!this._mediaPlayerItems.length) {
return html``;
}
if (this._loading) {
return html`<ha-circular-progress active></ha-circular-progress>`;
}
const mostRecentItem = this._mediaPlayerItems[
if (!this._mediaPlayerItems.length) {
return html``;
}
const currentItem = this._mediaPlayerItems[
this._mediaPlayerItems.length - 1
];
const previousItem =
const previousItem: MediaPlayerItem | undefined =
this._mediaPlayerItems.length > 1
? this._mediaPlayerItems[this._mediaPlayerItems.length - 2]
: undefined;
const hasExpandableChildren:
| MediaPlayerItem
| undefined = this._hasExpandableChildren(mostRecentItem.children);
| undefined = this._hasExpandableChildren(currentItem.children);
const showImages = mostRecentItem.children?.some(
(child) => child.thumbnail && child.thumbnail !== mostRecentItem.thumbnail
const showImages: boolean | undefined = currentItem.children?.some(
(child) => child.thumbnail && child.thumbnail !== currentItem.thumbnail
);
const mediaType = this.hass.localize(
`ui.components.media-browser.content-type.${mostRecentItem.media_content_type}`
`ui.components.media-browser.content-type.${currentItem.media_content_type}`
);
return html`
<div
class="header ${classMap({
"no-img": !mostRecentItem.thumbnail,
"no-img": !currentItem.thumbnail,
})}"
>
<div class="header-content">
${mostRecentItem.thumbnail
${currentItem.thumbnail
? html`
<div
class="img"
style="background-image: url(${mostRecentItem.thumbnail})"
style="background-image: url(${currentItem.thumbnail})"
>
${this._narrow && mostRecentItem?.can_play
${this._narrow && currentItem?.can_play
? html`
<mwc-fab
mini
.item=${mostRecentItem}
.item=${currentItem}
@click=${this._actionClicked}
>
<ha-svg-icon
@ -153,35 +156,29 @@ export class HaMediaPlayerBrowse extends LitElement {
`
: html``}
<div class="header-info">
${this.hideTitle && (this._narrow || !mostRecentItem.thumbnail)
? ""
: html`<div class="breadcrumb-overflow">
<div class="breadcrumb">
${!this.hideBack && previousItem
? html`
<div
class="previous-title"
@click=${this.navigateBack}
>
<ha-svg-icon .path=${mdiArrowLeft}></ha-svg-icon>
${previousItem.title}
</div>
`
: ""}
<h1 class="title">${mostRecentItem.title}</h1>
${mediaType
? html`<h2 class="subtitle">
${mediaType}
</h2>`
: ""}
</div>
</div>`}
${mostRecentItem?.can_play &&
(!mostRecentItem.thumbnail || !this._narrow)
<div class="breadcrumb">
${previousItem
? html`
<div class="previous-title" @click=${this.navigateBack}>
<ha-svg-icon .path=${mdiArrowLeft}></ha-svg-icon>
${previousItem.title}
</div>
`
: ""}
<h1 class="title">${currentItem.title}</h1>
${mediaType
? html`
<h2 class="subtitle">
${mediaType}
</h2>
`
: ""}
</div>
${currentItem.can_play && (!currentItem.thumbnail || !this._narrow)
? html`
<mwc-button
raised
.item=${mostRecentItem}
.item=${currentItem}
@click=${this._actionClicked}
>
<ha-svg-icon
@ -207,73 +204,69 @@ export class HaMediaPlayerBrowse extends LitElement {
class="header_button"
dir=${computeRTLDirection(this.hass)}
>
<ha-svg-icon path=${mdiClose}></ha-svg-icon>
<ha-svg-icon .path=${mdiClose}></ha-svg-icon>
</mwc-icon-button>
`
: ""}
</div>
${mostRecentItem.children?.length
${currentItem.children?.length
? hasExpandableChildren
? html`
<div class="children">
${mostRecentItem.children?.length
? html`
${mostRecentItem.children.map(
(child) => html`
<div
class="child"
.item=${child}
@click=${this._navigateForward}
>
<div class="ha-card-parent">
<ha-card
outlined
style="background-image: url(${child.thumbnail})"
${currentItem.children.map(
(child) => html`
<div
class="child"
.item=${child}
@click=${this._navigateForward}
>
<div class="ha-card-parent">
<ha-card
outlined
style="background-image: url(${child.thumbnail})"
>
${child.can_expand && !child.thumbnail
? html`
<ha-svg-icon
class="folder"
.path=${mdiFolder}
></ha-svg-icon>
`
: ""}
</ha-card>
${child.can_play
? html`
<mwc-icon-button
class="play"
.item=${child}
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
@click=${this._actionClicked}
>
${child.can_expand && !child.thumbnail
? html`
<ha-svg-icon
class="folder"
.path=${mdiFolder}
></ha-svg-icon>
`
: ""}
</ha-card>
${child.can_play
? html`
<mwc-icon-button
class="play"
.item=${child}
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
@click=${this._actionClicked}
>
<ha-svg-icon
.path=${this.action === "play"
? mdiPlay
: mdiPlus}
></ha-svg-icon>
</mwc-icon-button>
`
: ""}
</div>
<div class="title">${child.title}</div>
<div class="type">
${this.hass.localize(
`ui.components.media-browser.content-type.${child.media_content_type}`
)}
</div>
</div>
`
)}
`
: ""}
<ha-svg-icon
.path=${this.action === "play"
? mdiPlay
: mdiPlus}
></ha-svg-icon>
</mwc-icon-button>
`
: ""}
</div>
<div class="title">${child.title}</div>
<div class="type">
${this.hass.localize(
`ui.components.media-browser.content-type.${child.media_content_type}`
)}
</div>
</div>
`
)}
</div>
`
: html`
<mwc-list>
${mostRecentItem.children.map(
${currentItem.children.map(
(child) => html`
<mwc-list-item
@click=${this._actionClicked}
@ -353,10 +346,7 @@ export class HaMediaPlayerBrowse extends LitElement {
}
private _runAction(item: MediaPlayerItem): void {
fireEvent(this, "media-picked", {
media_content_id: item.media_content_id,
media_content_type: item.media_content_type,
});
fireEvent(this, "media-picked", { item });
}
private async _navigateForward(ev: MouseEvent): Promise<void> {
@ -383,12 +373,15 @@ export class HaMediaPlayerBrowse extends LitElement {
mediaContentId?: string,
mediaContentType?: string
): Promise<MediaPlayerItem> {
const itemData = await browseMediaPlayer(
this.hass,
this.entityId,
!mediaContentId ? undefined : mediaContentId,
mediaContentType
);
const itemData =
this.entityId !== BROWSER_SOURCE
? await browseMediaPlayer(
this.hass,
this.entityId,
mediaContentId,
mediaContentType
)
: await browseLocalMediaPlayer(this.hass, mediaContentId);
return itemData;
}
@ -485,12 +478,6 @@ export class HaMediaPlayerBrowse extends LitElement {
display: block;
}
.breadcrumb-overflow {
display: flex;
flex-grow: 1;
justify-content: space-between;
}
.breadcrumb {
display: flex;
flex-direction: column;
@ -716,6 +703,10 @@ export class HaMediaPlayerBrowse extends LitElement {
-webkit-line-clamp: 1;
}
:host(:not([narrow])[scroll]) .header-info {
height: 75px;
}
:host([scroll]) .header-info mwc-button,
.no-img .header-info mwc-button {
padding-right: 4px;

View File

@ -20,9 +20,10 @@ export const CONTRAST_RATIO = 4.5;
export type MediaPlayerBrowseAction = "pick" | "play";
export const BROWSER_SOURCE = "browser";
export interface MediaPickedEvent {
media_content_id: string;
media_content_type: string;
item: MediaPlayerItem;
}
export interface MediaPlayerThumbnail {
@ -58,6 +59,15 @@ export const browseMediaPlayer = (
media_content_type: mediaContentType,
});
export const browseLocalMediaPlayer = (
hass: HomeAssistant,
mediaContentId?: string
): Promise<MediaPlayerItem> =>
hass.callWS<MediaPlayerItem>({
type: "media_source/browse_media",
media_content_id: mediaContentId,
});
export const getCurrentProgress = (stateObj: HassEntity): number => {
let progress = stateObj.attributes.media_position;

View File

@ -409,8 +409,8 @@ class MoreInfoMediaPlayer extends LitElement {
entityId: this.stateObj!.entity_id,
mediaPickedCallback: (pickedMedia: MediaPickedEvent) =>
this._playMedia(
pickedMedia.media_content_id,
pickedMedia.media_content_type
pickedMedia.item.media_content_id,
pickedMedia.item.media_content_type
),
});
}

View File

@ -1,6 +1,13 @@
import { PolymerElement } from "@polymer/polymer";
import {
STATE_NOT_RUNNING,
STATE_RUNNING,
STATE_STARTING,
} from "home-assistant-js-websocket";
import { customElement, property, PropertyValues } from "lit-element";
import { deepActiveElement } from "../common/dom/deep-active-element";
import { deepEqual } from "../common/util/deep-equal";
import { CustomPanelInfo } from "../data/panel_custom";
import { HomeAssistant, Panels } from "../types";
import { removeInitSkeleton } from "../util/init-skeleton";
import {
@ -8,13 +15,6 @@ import {
RouteOptions,
RouterOptions,
} from "./hass-router-page";
import {
STATE_STARTING,
STATE_NOT_RUNNING,
STATE_RUNNING,
} from "home-assistant-js-websocket";
import { CustomPanelInfo } from "../data/panel_custom";
import { deepActiveElement } from "../common/dom/deep-active-element";
const CACHE_URL_PATHS = ["lovelace", "developer-tools"];
const COMPONENTS = {
@ -64,6 +64,10 @@ const COMPONENTS = {
import(
/* webpackChunkName: "panel-shopping-list" */ "../panels/shopping-list/ha-panel-shopping-list"
),
"media-browser": () =>
import(
/* webpackChunkName: "panel-media-browser" */ "../panels/media-browser/ha-panel-media-browser"
),
};
const getRoutes = (panels: Panels): RouterOptions => {

View File

@ -667,8 +667,8 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard {
entityId: this._config!.entity,
mediaPickedCallback: (pickedMedia: MediaPickedEvent) =>
this._playMedia(
pickedMedia.media_content_id,
pickedMedia.media_content_type
pickedMedia.item.media_content_id,
pickedMedia.item.media_content_type
),
});
}

View File

@ -0,0 +1,151 @@
import "@material/mwc-icon-button";
import { mdiPlayNetwork } from "@mdi/js";
import "@polymer/app-layout/app-header/app-header";
import "@polymer/app-layout/app-toolbar/app-toolbar";
import {
css,
CSSResultArray,
customElement,
html,
LitElement,
property,
TemplateResult,
} from "lit-element";
import { LocalStorage } from "../../common/decorators/local-storage";
import { HASSDomEvent } from "../../common/dom/fire_event";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { supportsFeature } from "../../common/entity/supports-feature";
import "../../components/ha-menu-button";
import "../../components/media-player/ha-media-player-browse";
import {
BROWSER_SOURCE,
MediaPickedEvent,
SUPPORT_BROWSE_MEDIA,
} from "../../data/media-player";
import "../../layouts/ha-app-layout";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showWebBrowserPlayMediaDialog } from "./show-media-player-dialog";
import { showSelectMediaPlayerDialog } from "./show-select-media-source-dialog";
@customElement("ha-panel-media-browser")
class PanelMediaBrowser extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true })
public narrow!: boolean;
// @ts-ignore
@LocalStorage("mediaBrowseEntityId", true)
private _entityId = BROWSER_SOURCE;
protected render(): TemplateResult {
const stateObj = this._entityId
? this.hass.states[this._entityId]
: undefined;
const title =
this._entityId === BROWSER_SOURCE
? `${this.hass.localize("ui.components.media-browser.web-browser")} - `
: stateObj?.attributes.friendly_name
? `${stateObj?.attributes.friendly_name} - `
: undefined;
return html`
<ha-app-layout>
<app-header fixed slot="header">
<app-toolbar>
<ha-menu-button
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
<div main-title>
${title || ""}${this.hass.localize(
"ui.components.media-browser.media-player-browser"
)}
</div>
<mwc-icon-button
.label=${this.hass.localize(
"ui.components.media-browser.choose-player"
)}
@click=${this._showSelectMediaPlayerDialog}
>
<ha-svg-icon .path=${mdiPlayNetwork}></ha-svg-icon>
</mwc-icon-button>
</app-toolbar>
</app-header>
<div class="content">
<ha-media-player-browse
.hass=${this.hass}
.entityId=${this._entityId}
@media-picked=${this._mediaPicked}
></ha-media-player-browse>
</div>
</ha-app-layout>
`;
}
private _showSelectMediaPlayerDialog(): void {
showSelectMediaPlayerDialog(this, {
mediaSources: this._mediaPlayerEntities,
sourceSelectedCallback: (entityId) => {
this._entityId = entityId;
},
});
}
private async _mediaPicked(
ev: HASSDomEvent<MediaPickedEvent>
): Promise<void> {
const item = ev.detail.item;
if (this._entityId === BROWSER_SOURCE) {
const resolvedUrl: any = await this.hass.callWS({
type: "media_source/resolve_media",
media_content_id: item.media_content_id,
});
showWebBrowserPlayMediaDialog(this, {
sourceUrl: resolvedUrl.url,
sourceType: resolvedUrl.mime_type,
title: item.title,
});
return;
}
this.hass!.callService("media_player", "play_media", {
entity_id: this._entityId,
media_content_id: item.media_content_id,
media_content_type: item.media_content_type,
});
}
private get _mediaPlayerEntities() {
return Object.values(this.hass!.states).filter((entity) => {
if (
computeStateDomain(entity) === "media_player" &&
supportsFeature(entity, SUPPORT_BROWSE_MEDIA)
) {
return true;
}
return false;
});
}
static get styles(): CSSResultArray {
return [
haStyle,
css`
ha-media-player-browse {
height: calc(100vh - 84px);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-panel-media-browser": PanelMediaBrowser;
}
}

View File

@ -0,0 +1,93 @@
import "@polymer/paper-item/paper-item";
import "@polymer/paper-listbox/paper-listbox";
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from "lit-element";
import { fireEvent } from "../../common/dom/fire_event";
import { createCloseHeading } from "../../components/ha-dialog";
import { BROWSER_SOURCE } from "../../data/media-player";
import type { HomeAssistant } from "../../types";
import type { SelectMediaPlayerDialogParams } from "./show-select-media-source-dialog";
@customElement("hui-dialog-select-media-player")
export class HuiDialogSelectMediaPlayer extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
private _params?: SelectMediaPlayerDialogParams;
public showDialog(params: SelectMediaPlayerDialogParams): void {
this._params = params;
}
public closeDialog() {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render(): TemplateResult {
if (!this._params) {
return html``;
}
return html`
<ha-dialog
open
scrimClickAction
escapeKeyAction
hideActions
.heading=${createCloseHeading(
this.hass,
this.hass.localize(`ui.components.media-browser.choose_player`)
)}
@closed=${this.closeDialog}
>
<paper-listbox
attr-for-selected="itemName"
@iron-select=${this._selectSource}
><paper-item .itemName=${BROWSER_SOURCE}
>${this.hass.localize(
"ui.components.media-browser.web-browser"
)}</paper-item
>
${this._params.mediaSources.map(
(source) => html`
<paper-item .itemName=${source.entity_id}
>${source.attributes.friendly_name}</paper-item
>
`
)}
</paper-listbox>
</ha-dialog>
`;
}
private _selectSource(ev: CustomEvent): void {
const entityId = ev.detail.item.itemName;
this._params!.sourceSelectedCallback(entityId);
this.closeDialog();
}
static get styles(): CSSResult {
return css`
ha-dialog {
--dialog-content-padding: 0 24px 20px;
}
paper-item {
cursor: pointer;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-select-media-player": HuiDialogSelectMediaPlayer;
}
}

View File

@ -0,0 +1,122 @@
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from "lit-element";
import { fireEvent } from "../../common/dom/fire_event";
import { createCloseHeading } from "../../components/ha-dialog";
import "../../components/ha-hls-player";
import type { HomeAssistant } from "../../types";
import { WebBrowserPlayMediaDialogParams } from "./show-media-player-dialog";
@customElement("hui-dialog-web-browser-play-media")
export class HuiDialogWebBrowserPlayMedia extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
private _params?: WebBrowserPlayMediaDialogParams;
public showDialog(params: WebBrowserPlayMediaDialogParams): void {
this._params = params;
}
public closeDialog() {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render(): TemplateResult {
if (!this._params || !this._params.sourceType || !this._params.sourceUrl) {
return html``;
}
const mediaType = this._params.sourceType.split("/", 1)[0];
return html`
<ha-dialog
open
scrimClickAction
escapeKeyAction
hideActions
.heading=${createCloseHeading(
this.hass,
this._params.title ||
this.hass.localize("ui.components.media-browser.media_player")
)}
@closed=${this.closeDialog}
>
${mediaType === "audio"
? html`
<audio controls autoplay>
<source
src=${this._params.sourceUrl}
type=${this._params.sourceType}
/>
${this.hass.localize(
"ui.components.media-browser.audio_not_supported"
)}
</audio>
`
: mediaType === "video"
? html`
<video controls autoplay playsinline>
<source
src=${this._params.sourceUrl}
type=${this._params.sourceType}
/>
${this.hass.localize(
"ui.components.media-browser.video_not_supported"
)}
</video>
`
: this._params.sourceType === "application/x-mpegURL"
? html`
<ha-hls-player
controls
autoplay
playsinline
.hass=${this.hass}
.url=${this._params.sourceUrl}
></ha-hls-player>
`
: mediaType === "image"
? html`<img src=${this._params.sourceUrl} />`
: html`${this.hass.localize(
"ui.components.media-browser.media_not_supported"
)}`}
</ha-dialog>
`;
}
static get styles(): CSSResult {
return css`
ha-dialog {
--mdc-dialog-heading-ink-color: var(--primary-text-color);
}
@media (min-width: 800px) {
ha-dialog {
--mdc-dialog-max-width: 800px;
--mdc-dialog-min-width: 400px;
}
}
video,
audio,
img {
outline: none;
width: 100%;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-web-browser-play-media": HuiDialogWebBrowserPlayMedia;
}
}

View File

@ -0,0 +1,21 @@
import { fireEvent } from "../../common/dom/fire_event";
export interface WebBrowserPlayMediaDialogParams {
sourceUrl: string;
sourceType: string;
title?: string;
}
export const showWebBrowserPlayMediaDialog = (
element: HTMLElement,
webBrowserPlayMediaDialogParams: WebBrowserPlayMediaDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-web-browser-play-media",
dialogImport: () =>
import(
/* webpackChunkName: "hui-dialog-media-player" */ "./hui-dialog-web-browser-play-media"
),
dialogParams: webBrowserPlayMediaDialogParams,
});
};

View File

@ -0,0 +1,21 @@
import { HassEntity } from "home-assistant-js-websocket";
import { fireEvent } from "../../common/dom/fire_event";
export interface SelectMediaPlayerDialogParams {
mediaSources: HassEntity[];
sourceSelectedCallback: (entityId: string) => void;
}
export const showSelectMediaPlayerDialog = (
element: HTMLElement,
selectMediaPlayereDialogParams: SelectMediaPlayerDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-select-media-player",
dialogImport: () =>
import(
/* webpackChunkName: "hui-dialog-select-media-player" */ "./hui-dialog-select-media-player"
),
dialogParams: selectMediaPlayereDialogParams,
});
};

View File

@ -357,8 +357,13 @@
"play-media": "Play Media",
"pick-media": "Pick Media",
"no_items": "No items",
"choose-source": "Choose Source",
"choose_player": "Choose Player",
"media-player-browser": "Media Player Browser",
"web-browser": "Web Browser",
"media_player": "Media Player",
"audio_not_supported": "Your browser does not support the audio element.",
"video_not_supported": "Your browser does not support the video element.",
"media_not_supported": "The Browser Media Player does not support this type of media",
"content-type": {
"server": "Server",
"library": "Library",