Convert the viewer into a carousel.
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"@material/image-list": "^12.0.0",
|
||||
"custom-card-helpers": "^1.8.0",
|
||||
"dayjs": "^1.10.7",
|
||||
"embla-carousel": "^5.0.1",
|
||||
"home-assistant-js-websocket": "^5.11.1",
|
||||
"lit": "^2.0.2",
|
||||
"screenfull": "^5.1.0",
|
||||
|
||||
+5
-1
@@ -43,6 +43,10 @@ export async function homeAssistantWSRequest<T>(
|
||||
return parseResult.data;
|
||||
}
|
||||
|
||||
export function isTrueMedia(media: BrowseMediaSource): boolean {
|
||||
return !media.can_expand;
|
||||
}
|
||||
|
||||
// From a BrowseMediaSource item extract the first true media item (i.e. a
|
||||
// clip/snapshot, not a folder).
|
||||
export function getFirstTrueMediaChildIndex(
|
||||
@@ -52,7 +56,7 @@ export function getFirstTrueMediaChildIndex(
|
||||
return null;
|
||||
}
|
||||
for (let i = 0; i < media.children.length; i++) {
|
||||
if (!media.children[i].can_expand) {
|
||||
if (isTrueMedia(media.children[i])) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,76 +2,54 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
|
||||
import { BrowseMediaSource, NextPreviousControlStyle } from '../types.js';
|
||||
|
||||
import { View } from '../view.js';
|
||||
import { NextPreviousControlStyle } from '../types.js';
|
||||
|
||||
import controlStyle from '../scss/next-previous-control.scss';
|
||||
|
||||
@customElement('frigate-card-next-previous-control')
|
||||
export class FrigateCardMessage extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected control!: "next" | "previous";
|
||||
protected direction?: 'next' | 'previous';
|
||||
|
||||
@property({ attribute: false })
|
||||
protected controlStyle!: NextPreviousControlStyle;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected parent!: BrowseMediaSource;
|
||||
protected controlStyle?: NextPreviousControlStyle;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected childIndex!: number;
|
||||
protected thumbnail?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected view!: View;
|
||||
|
||||
protected _changeView(): void {
|
||||
new View({
|
||||
view: this.view.view,
|
||||
target: this.parent,
|
||||
childIndex: this.childIndex,
|
||||
}).dispatchChangeEvent(this);
|
||||
}
|
||||
|
||||
protected render() : TemplateResult {
|
||||
if (this.controlStyle == 'none' || !this.parent.children) {
|
||||
return html``;
|
||||
}
|
||||
const target = this.parent.children[this.childIndex];
|
||||
if (!target) {
|
||||
protected render(): TemplateResult {
|
||||
if (!this.controlStyle || this.controlStyle == 'none') {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const classes = {
|
||||
controls: true,
|
||||
previous: this.control == "previous",
|
||||
next: this.control == "next",
|
||||
thumbnails: this.controlStyle == "thumbnails",
|
||||
chevrons: this.controlStyle == "chevrons",
|
||||
button: this.controlStyle == "chevrons",
|
||||
previous: this.direction == 'previous',
|
||||
next: this.direction == 'next',
|
||||
thumbnails: this.controlStyle == 'thumbnails',
|
||||
chevrons: this.controlStyle == 'chevrons',
|
||||
button: this.controlStyle == 'chevrons',
|
||||
};
|
||||
|
||||
if (this.controlStyle == "chevrons") {
|
||||
if (this.controlStyle == 'chevrons') {
|
||||
return html` <ha-icon-button
|
||||
icon=${this.control == "previous" ? 'mdi:chevron-left' : 'mdi:chevron-right'}
|
||||
icon=${this.direction == 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right'}
|
||||
class="${classMap(classes)}"
|
||||
title=${target.title}
|
||||
@click=${this._changeView}
|
||||
title=${this.title}
|
||||
></ha-icon-button>`;
|
||||
}
|
||||
|
||||
if (!target.thumbnail) {
|
||||
if (!this.thumbnail) {
|
||||
return html``;
|
||||
}
|
||||
return html`<img
|
||||
src="${target.thumbnail}"
|
||||
src="${this.thumbnail}"
|
||||
class="${classMap(classes)}"
|
||||
title="${target.title}"
|
||||
@click=${this._changeView}
|
||||
title="${this.title}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(controlStyle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+337
-112
@@ -1,7 +1,15 @@
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
LitElement,
|
||||
TemplateResult,
|
||||
html,
|
||||
unsafeCSS,
|
||||
PropertyValues,
|
||||
} from 'lit';
|
||||
import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { until } from 'lit/directives/until.js';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat.js';
|
||||
@@ -13,6 +21,7 @@ import type {
|
||||
ExtendedHomeAssistant,
|
||||
NextPreviousControlStyle,
|
||||
} from '../types.js';
|
||||
import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import {
|
||||
browseMediaQuery,
|
||||
@@ -22,17 +31,15 @@ import {
|
||||
dispatchPauseEvent,
|
||||
dispatchPlayEvent,
|
||||
getFirstTrueMediaChildIndex,
|
||||
isTrueMedia,
|
||||
} from '../common.js';
|
||||
|
||||
import { View } from '../view.js';
|
||||
import {
|
||||
renderProgressIndicator,
|
||||
} from '../components/message.js';
|
||||
import { renderProgressIndicator } from '../components/message.js';
|
||||
|
||||
import './next-prev-control.js';
|
||||
|
||||
import viewerStyle from '../scss/viewer.scss';
|
||||
import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js';
|
||||
|
||||
// Load dayjs plugin(s).
|
||||
dayjs.extend(dayjs_custom_parse_format);
|
||||
@@ -61,6 +68,33 @@ export class FrigateCardViewer extends LitElement {
|
||||
return html`${until(this._render(), renderProgressIndicator())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all the given media for a target.
|
||||
* @param target The target to resolve media from.
|
||||
* @returns True if the resolutions were all error free.
|
||||
*/
|
||||
protected async _resolveAllMediaForTarget(target: BrowseMediaSource): Promise<boolean> {
|
||||
if (!this.hass) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let errorFree = true;
|
||||
for (let i = 0; target.children && i < (target.children || []).length; ++i) {
|
||||
if (isTrueMedia(target.children[i])) {
|
||||
errorFree &&= !!(await ResolvedMediaUtil.resolveMedia(
|
||||
this.hass,
|
||||
target.children[i],
|
||||
this.resolvedMediaCache,
|
||||
));
|
||||
}
|
||||
}
|
||||
return errorFree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asyncronously render the element.
|
||||
* @returns A template to render.
|
||||
*/
|
||||
protected async _render(): Promise<TemplateResult | void> {
|
||||
if (!this.hass || !this.view || !this.browseMediaQueryParameters) {
|
||||
return html``;
|
||||
@@ -90,7 +124,7 @@ export class FrigateCardViewer extends LitElement {
|
||||
view: this.view.view,
|
||||
target: parent,
|
||||
childIndex: childIndex,
|
||||
})
|
||||
});
|
||||
|
||||
// In this block, no clip has been manually selected, so this is loading
|
||||
// the most recent clip on card load. In this mode, autoplay of the clip
|
||||
@@ -100,31 +134,29 @@ export class FrigateCardViewer extends LitElement {
|
||||
autoplay = this.autoplayClip ?? true;
|
||||
}
|
||||
|
||||
const resolvedMedia = await ResolvedMediaUtil.resolveMedia(
|
||||
this.hass, view.media, this.resolvedMediaCache);
|
||||
if (!resolvedMedia) {
|
||||
// Home Assistant could not resolve media item.
|
||||
if (view.target && !(await this._resolveAllMediaForTarget(view.target))) {
|
||||
return dispatchErrorMessageEvent(this, localize('error.could_not_resolve'));
|
||||
}
|
||||
|
||||
return html`
|
||||
<frigate-card-viewer-core
|
||||
.view=${view}
|
||||
.nextPreviousControlStyle=${this.nextPreviousControlStyle}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.autoplayClip=${autoplay}
|
||||
.hass=${this.hass}
|
||||
.browseMediaQueryParameters=${this.browseMediaQueryParameters}
|
||||
>
|
||||
</frigate-card-viewer-core>`;
|
||||
return html` <frigate-card-viewer-core
|
||||
.view=${view}
|
||||
.nextPreviousControlStyle=${this.nextPreviousControlStyle}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.autoplayClip=${autoplay}
|
||||
.hass=${this.hass}
|
||||
.browseMediaQueryParameters=${this.browseMediaQueryParameters}
|
||||
>
|
||||
</frigate-card-viewer-core>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(viewerStyle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@customElement('frigate-card-viewer-core')
|
||||
export class FrigateCardViewerCore extends LitElement {
|
||||
@property({ attribute: false })
|
||||
@@ -145,6 +177,58 @@ export class FrigateCardViewerCore extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected browseMediaQueryParameters?: BrowseMediaQueryParameters;
|
||||
|
||||
// Media carousel object.
|
||||
protected _carousel?: EmblaCarouselType;
|
||||
protected _loadedCarousel = false;
|
||||
|
||||
// Mapping of slide # to BrowseMediaSource child #.
|
||||
// (Folders are not media items that can be rendered).
|
||||
protected _slideToChild: Record<number, number> = {};
|
||||
|
||||
/**
|
||||
* The updated lifecycle callback for this element.
|
||||
* @param changedProperties The properties that were changed in this render.
|
||||
*/
|
||||
updated(changedProperties: PropertyValues): void {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (!this._loadedCarousel) {
|
||||
this.updateComplete.then(() => {
|
||||
this._loadCarousel();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the carousel with "slides" (clips or snapshots).
|
||||
*/
|
||||
protected _loadCarousel(): void {
|
||||
const carouselNode = this.renderRoot.querySelector(
|
||||
'.embla__viewport',
|
||||
) as HTMLElement;
|
||||
|
||||
if (carouselNode) {
|
||||
this._loadedCarousel = true;
|
||||
|
||||
// Start the carousel on the selected child number.
|
||||
const startIndex = Number(
|
||||
Object.keys(this._slideToChild).find(
|
||||
(key) => this._slideToChild[key] === this.view?.childIndex,
|
||||
),
|
||||
);
|
||||
|
||||
this._carousel = EmblaCarousel(carouselNode, {
|
||||
startIndex: isNaN(startIndex) ? undefined : startIndex,
|
||||
});
|
||||
this._carousel.on('select', this._slideSelectHandler.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the event start time from a media object.
|
||||
* @param browseMedia The media object to extract the start time from.
|
||||
* @returns The start time in unix/epoch time, or null if it cannot be determined.
|
||||
*/
|
||||
protected _extractEventStartTimeFromBrowseMedia(
|
||||
browseMedia: BrowseMediaSource,
|
||||
): number | null {
|
||||
@@ -162,12 +246,18 @@ export class FrigateCardViewerCore extends LitElement {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the previous and next real media items, given the index
|
||||
protected _getMediaNeighbors() : BrowseMediaNeighbors | null {
|
||||
if (!this.view ||
|
||||
!this.view.target ||
|
||||
!this.view.target.children ||
|
||||
this.view.childIndex === undefined) {
|
||||
/**
|
||||
* Get the previous and next true media items from the current view.
|
||||
* @returns A BrowseMediaNeighbors with indices and objects of true media
|
||||
* neighbors.
|
||||
*/
|
||||
protected _getMediaNeighbors(): BrowseMediaNeighbors | null {
|
||||
if (
|
||||
!this.view ||
|
||||
!this.view.target ||
|
||||
!this.view.target.children ||
|
||||
this.view.childIndex === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -175,7 +265,7 @@ export class FrigateCardViewerCore extends LitElement {
|
||||
let prevIndex: number | null = null;
|
||||
for (let i = this.view.childIndex - 1; i >= 0; i--) {
|
||||
const media = this.view.target.children[i];
|
||||
if (media && !media.can_expand) {
|
||||
if (media && isTrueMedia(media)) {
|
||||
prevIndex = i;
|
||||
break;
|
||||
}
|
||||
@@ -185,7 +275,7 @@ export class FrigateCardViewerCore extends LitElement {
|
||||
let nextIndex: number | null = null;
|
||||
for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) {
|
||||
const media = this.view.target.children[i];
|
||||
if (media && !media.can_expand) {
|
||||
if (media && isTrueMedia(media)) {
|
||||
nextIndex = i;
|
||||
break;
|
||||
}
|
||||
@@ -199,115 +289,250 @@ export class FrigateCardViewerCore extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
// Get a clip at the same time as a snapshot.
|
||||
protected async _findRelatedClips(
|
||||
snapshot: BrowseMediaSource | null,
|
||||
): Promise<BrowseMediaSource | null> {
|
||||
if (!snapshot || !this.hass || !this.browseMediaQueryParameters) {
|
||||
/**
|
||||
* Get a clip view that matches a given snapshot. Includes clips within the
|
||||
* same range as the current view.
|
||||
* @param snapshot The snapshot to find a matching clip for.
|
||||
* @returns The view that would show the matching clip.
|
||||
*/
|
||||
protected async _findRelatedClipView(
|
||||
snapshot: BrowseMediaSource,
|
||||
): Promise<View | null> {
|
||||
if (!this.hass ||
|
||||
!this.view ||
|
||||
!this.view.target ||
|
||||
!this.view.target.children ||
|
||||
!this.view.target.children.length ||
|
||||
!this.browseMediaQueryParameters) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startTime = this._extractEventStartTimeFromBrowseMedia(snapshot);
|
||||
if (startTime) {
|
||||
try {
|
||||
// Fetch clips within the same second (same camera/zone/label, etc).
|
||||
const clipsAtSameTime = await browseMediaQuery(this.hass, {
|
||||
...this.browseMediaQueryParameters,
|
||||
mediaType: 'clips',
|
||||
before: startTime + 1,
|
||||
after: startTime,
|
||||
const snapshotStartTime = this._extractEventStartTimeFromBrowseMedia(snapshot);
|
||||
if (!snapshotStartTime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Heuristic: At this point, the user has a particular snapshot that they
|
||||
// are interested in and want to see a related clip, yet the viewer code
|
||||
// does not know the exact search criteria that led to that snapshot (e.g.
|
||||
// it could be a 10-deep folder in the gallery). To give the user to ability
|
||||
// to 'navigate' in the clips view once they change into that mode, this
|
||||
// heuristic finds the earliest and latest snapshot that the user is
|
||||
// currently viewing and mirrors that range into the clips view. Then,
|
||||
// within the results see if there's a clip that matches the same time as
|
||||
// the snapshot.
|
||||
let earliest: number | null = null, latest: number | null = null;
|
||||
for (let i = 0; i < this.view.target.children.length; i++) {
|
||||
const child = this.view.target.children[i];
|
||||
if (!isTrueMedia(child)) {
|
||||
continue;
|
||||
}
|
||||
const startTime = this._extractEventStartTimeFromBrowseMedia(child);
|
||||
|
||||
if (startTime && (earliest === null || startTime < earliest)) {
|
||||
earliest = startTime;
|
||||
}
|
||||
if (startTime && (latest === null || startTime > latest)) {
|
||||
latest = startTime;
|
||||
}
|
||||
}
|
||||
if (!earliest || !latest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let clips: BrowseMediaSource | null;
|
||||
|
||||
try {
|
||||
clips = await browseMediaQuery(this.hass, {
|
||||
...this.browseMediaQueryParameters,
|
||||
mediaType: 'clips',
|
||||
before: latest,
|
||||
after: earliest,
|
||||
});
|
||||
} catch (e) {
|
||||
// This is best effort.
|
||||
return null
|
||||
}
|
||||
|
||||
console.info(clips);
|
||||
if (!clips || !clips.children || !clips.children.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < clips.children.length; i++) {
|
||||
const child = clips.children[i];
|
||||
if (!isTrueMedia(child)) {
|
||||
continue;
|
||||
}
|
||||
const clipStartTime = this._extractEventStartTimeFromBrowseMedia(child);
|
||||
if (clipStartTime && clipStartTime === snapshotStartTime) {
|
||||
return new View({
|
||||
view: 'clip',
|
||||
target: clips,
|
||||
childIndex: i,
|
||||
previous: this.view
|
||||
});
|
||||
if (clipsAtSameTime) {
|
||||
const index = getFirstTrueMediaChildIndex(clipsAtSameTime);
|
||||
if (index != null && clipsAtSameTime.children?.length) {
|
||||
return clipsAtSameTime.children[index];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Pass. This is best effort.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the user selecting a new slide in the carousel.
|
||||
*/
|
||||
protected _slideSelectHandler(): void {
|
||||
if (!this._carousel || !this.view) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the childIndex in the view (without re-render)
|
||||
const slidesInView = this._carousel.slidesInView(true);
|
||||
if (slidesInView.length) {
|
||||
const childIndex = this._slideToChild[slidesInView[0]];
|
||||
if (childIndex !== undefined) {
|
||||
// Update the currently live view in place.
|
||||
this.view.childIndex = childIndex;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a next/previous control interaction.
|
||||
* @param direction The direction requested, previous or next.
|
||||
*/
|
||||
protected _nextPreviousHandler(direction: 'previous' | 'next'): void {
|
||||
console.info(`next/prev handler: ${direction}`);
|
||||
if (direction == 'previous') {
|
||||
this._carousel?.scrollPrev();
|
||||
} else if (direction == 'next') {
|
||||
this._carousel?.scrollNext();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the element.
|
||||
* @returns A template to display to the user.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.view || !this.resolvedMediaCache) {
|
||||
if (
|
||||
!this.view ||
|
||||
!this.view.target ||
|
||||
!this.view.target.children ||
|
||||
!this.view.target.children.length ||
|
||||
this.view.childIndex === undefined ||
|
||||
!this.resolvedMediaCache
|
||||
) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const mediaToRender = this.view.media;
|
||||
const resolvedMedia = mediaToRender ? this.resolvedMediaCache.get(
|
||||
mediaToRender.media_content_id) : undefined;
|
||||
if (!mediaToRender || !resolvedMedia) {
|
||||
return html``;
|
||||
const slides: TemplateResult[] = [];
|
||||
this._slideToChild = {};
|
||||
|
||||
for (let i = 0; i < this.view.target.children?.length; ++i) {
|
||||
const slide = this._renderMediaItem(this.view.target.children[i]);
|
||||
if (slide) {
|
||||
this._slideToChild[slides.length] = i;
|
||||
slides.push(slide);
|
||||
}
|
||||
}
|
||||
|
||||
const neighbors = this._getMediaNeighbors();
|
||||
return html` <div>
|
||||
${neighbors?.previousIndex != null
|
||||
|
||||
return html`<div class="container">
|
||||
${neighbors && neighbors.previous
|
||||
? html`<frigate-card-next-previous-control
|
||||
.control=${'previous'}
|
||||
.direction=${'previous'}
|
||||
.controlStyle=${this.nextPreviousControlStyle}
|
||||
.parent=${this.view.target}
|
||||
.childIndex=${neighbors.previousIndex}
|
||||
.view=${this.view}
|
||||
.thumbnail=${neighbors.previous.thumbnail}
|
||||
.title=${neighbors.previous.title}
|
||||
@click=${() => this._nextPreviousHandler('previous')}
|
||||
></frigate-card-next-previous-control>`
|
||||
: ``}
|
||||
${this.view.is('clip')
|
||||
? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl'
|
||||
? html`<frigate-card-ha-hls-player
|
||||
.hass=${this.hass}
|
||||
.url=${resolvedMedia.url}
|
||||
title="${mediaToRender.title}"
|
||||
muted
|
||||
controls
|
||||
playsinline
|
||||
allow-exoplayer
|
||||
?autoplay="${this.autoplayClip}"
|
||||
>
|
||||
</frigate-card-ha-hls-player>`
|
||||
: html`<video
|
||||
title="${mediaToRender.title}"
|
||||
muted
|
||||
controls
|
||||
playsinline
|
||||
?autoplay="${this.autoplayClip}"
|
||||
@loadedmetadata=${(e) => dispatchMediaLoadEvent(this, e)}a
|
||||
@play=${() => dispatchPlayEvent(this)}
|
||||
@pause=${() => dispatchPauseEvent(this)}
|
||||
>
|
||||
<source src="${resolvedMedia.url}" type="${resolvedMedia.mime_type}" />
|
||||
</video>`
|
||||
: html`<img
|
||||
src=${resolvedMedia.url}
|
||||
title="${mediaToRender.title}"
|
||||
@click=${() => {
|
||||
// Get clips potentially related to this snapshot.
|
||||
this._findRelatedClips(mediaToRender).then((relatedClip) => {
|
||||
if (relatedClip) {
|
||||
new View({
|
||||
view: 'clip',
|
||||
target: relatedClip,
|
||||
}).dispatchChangeEvent(this);
|
||||
}
|
||||
});
|
||||
}}
|
||||
@load=${(e) => {
|
||||
dispatchMediaLoadEvent(this, e);
|
||||
}}
|
||||
/>`}
|
||||
${neighbors?.nextIndex != null
|
||||
<div class="embla">
|
||||
<div class="embla__viewport">
|
||||
<div class="embla__container">${slides}</div>
|
||||
</div>
|
||||
</div>
|
||||
${neighbors && neighbors.next
|
||||
? html`<frigate-card-next-previous-control
|
||||
.control=${'next'}
|
||||
.direction=${'next'}
|
||||
.controlStyle=${this.nextPreviousControlStyle}
|
||||
.parent=${this.view.target}
|
||||
.childIndex=${neighbors.nextIndex}
|
||||
.view=${this.view}
|
||||
.thumbnail=${neighbors.next.thumbnail}
|
||||
.title=${neighbors.next.title}
|
||||
@click=${() => this._nextPreviousHandler('next')}
|
||||
></frigate-card-next-previous-control>`
|
||||
: ``}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a given media item.
|
||||
* @param mediaToRender The media item to render.
|
||||
* @returns A template or void if the item could not be rendered.
|
||||
*/
|
||||
protected _renderMediaItem(mediaToRender: BrowseMediaSource): TemplateResult | void {
|
||||
// media that can be expanded (folders) cannot be resolved to a single media
|
||||
// item, skip them.
|
||||
if (!this.view || !isTrueMedia(mediaToRender)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedMedia = this.resolvedMediaCache?.get(mediaToRender.media_content_id);
|
||||
if (!resolvedMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="embla__slide">
|
||||
${this.view.is('clip')
|
||||
? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl'
|
||||
? html`<frigate-card-ha-hls-player
|
||||
.hass=${this.hass}
|
||||
.url=${resolvedMedia.url}
|
||||
title="${mediaToRender.title}"
|
||||
muted
|
||||
controls
|
||||
playsinline
|
||||
allow-exoplayer
|
||||
?autoplay="${this.autoplayClip}"
|
||||
>
|
||||
</frigate-card-ha-hls-player>`
|
||||
: html`<video
|
||||
title="${mediaToRender.title}"
|
||||
muted
|
||||
controls
|
||||
playsinline
|
||||
?autoplay="${this.autoplayClip}"
|
||||
@loadedmetadata="${(e) => dispatchMediaLoadEvent(this, e)}"
|
||||
@play=${() => dispatchPlayEvent(this)}
|
||||
@pause=${() => dispatchPauseEvent(this)}
|
||||
>
|
||||
<source src="${resolvedMedia.url}" type="${resolvedMedia.mime_type}" />
|
||||
</video>`
|
||||
: html`<img
|
||||
src=${resolvedMedia.url}
|
||||
title="${mediaToRender.title}"
|
||||
@click=${() => {
|
||||
if (this._carousel?.clickAllowed()) {
|
||||
this._findRelatedClipView(mediaToRender).then((view) => {
|
||||
if (view) {
|
||||
view.dispatchChangeEvent(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
@load=${(e) => {
|
||||
dispatchMediaLoadEvent(this, e);
|
||||
}}
|
||||
/>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(viewerStyle);
|
||||
}
|
||||
|
||||
+39
-3
@@ -2,11 +2,47 @@ ha-hls-player {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
img,video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
div {
|
||||
|
||||
div.container {
|
||||
// Keep the controls positioned relative to the video.
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.embla {
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.embla__viewport {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.embla__container {
|
||||
display: flex;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-khtml-user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.embla__viewport.is-draggable {
|
||||
cursor: move;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.embla__viewport.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.embla__slide {
|
||||
position: relative;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
margin-right: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
Reference in New Issue
Block a user