/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-env browser */ import { HomeAssistant } from 'custom-card-helpers'; import _ from 'lodash'; import Plex from './modules/Plex'; import PlayController from './modules/PlayController'; import { escapeHtml, getOffset } from './modules/utils'; import { CSS_STYLE, LOREM_IPSUM } from './const'; import style from './modules/style'; class PlexMeetsHomeAssistant extends HTMLElement { plexProtocol: 'http' | 'https' = 'http'; plex: Plex | undefined; playController: PlayController | undefined; movieElems: any = []; activeMovieElem: HTMLElement | undefined; activeMovieElemData: Record = {}; seasonElemFreshlyLoaded = false; episodesElemFreshlyLoaded = false; detailElem: HTMLElement | undefined; seasonsElem: HTMLElement | undefined; seasonsElemHidden = true; episodesElem: HTMLElement | undefined; episodesElemHidden = true; data: Record = {}; config: Record = {}; requestTimeout = 3000; loading = false; maxCount: false | number = false; playSupported = false; error = ''; content: any; previousPositions: Array = []; hassObj: HomeAssistant | undefined; contentBGHeight = 0; set hass(hass: HomeAssistant) { this.hassObj = hass; if (this.plex) { this.playController = new PlayController(this.hassObj, this.plex, this.config.entity_id); } if (!this.content) { if (this.playController) { this.playSupported = this.playController.isPlaySupported(); } this.error = ''; if (!this.loading) { this.loadInitialData(); } } } loadInitialData = async (): Promise => { this.loading = true; this.renderPage(); try { if (this.plex) { const [serverID, plexSections] = await Promise.all([this.plex.getServerID(), this.plex.getSectionsData()]); // eslint-disable-next-line @typescript-eslint/camelcase this.data.serverID = serverID; _.forEach(plexSections, section => { this.data[section.title1] = section.Metadata; }); if (this.data[this.config.libraryName] === undefined) { this.error = `Library name ${this.config.libraryName} does not exist.`; } this.loading = false; this.render(); } else { throw Error('Plex not initialized.'); } } catch (err) { // todo: proper timeout here this.error = `Plex server did not respond.
Details of the error: ${escapeHtml(err.message)}`; this.renderPage(); } }; render = (): void => { this.previousPositions = []; // todo: find a better way to detect resize... setInterval(() => { if (this.movieElems.length > 0) { let renderNeeded = false; if (this.previousPositions.length === 0) { for (let i = 0; i < this.movieElems.length; i += 1) { this.previousPositions[i] = {}; this.previousPositions[i].top = this.movieElems[i].parentElement.offsetTop; this.previousPositions[i].left = this.movieElems[i].parentElement.offsetLeft; } } for (let i = 0; i < this.movieElems.length; i += 1) { if ( this.previousPositions[i] && this.movieElems[i].dataset.clicked !== 'true' && (this.previousPositions[i].top !== this.movieElems[i].parentElement.offsetTop || this.previousPositions[i].left !== this.movieElems[i].parentElement.offsetLeft) ) { renderNeeded = true; this.previousPositions = []; } } if (renderNeeded) { this.renderPage(); const contentbg = this.getElementsByClassName('contentbg'); this.contentBGHeight = (contentbg[0] as HTMLElement).scrollHeight; } } }, 100); this.renderPage(); }; renderPage = (): void => { if (this) this.innerHTML = ''; const card = document.createElement('ha-card'); // card.header = this.config.libraryName; this.content = document.createElement('div'); this.content.style.padding = '16px 16px 100px'; this.content.innerHTML = ''; if (this.error !== '') { this.content.innerHTML = `Error: ${this.error}`; } else if (this.data[this.config.libraryName] && this.data[this.config.libraryName].length === 0) { this.content.innerHTML = `Library ${escapeHtml(this.config.libraryName)} has no items.`; } else if (this.loading) { this.content.style.padding = '16px 16px 16px'; this.content.innerHTML = '
'; } card.appendChild(this.content); this.appendChild(card); let count = 0; const contentbg = document.createElement('div'); contentbg.className = 'contentbg'; this.content.appendChild(contentbg); this.detailElem = document.createElement('div'); this.detailElem.className = 'detail'; this.detailElem.innerHTML = "

"; if (this.playSupported) { // todo: temp disabled // this.detailElem.innerHTML += ""; } this.content.appendChild(this.detailElem); this.seasonsElem = document.createElement('div'); this.seasonsElem.className = 'seasons'; this.seasonsElem.addEventListener('click', () => { this.hideBackground(); this.minimizeAll(); }); this.content.appendChild(this.seasonsElem); this.episodesElem = document.createElement('div'); this.episodesElem.className = 'episodes'; this.episodesElem.addEventListener('click', () => { this.hideBackground(); this.minimizeAll(); }); this.content.appendChild(this.episodesElem); // todo: figure out why timeout is needed here and do it properly setTimeout(() => { contentbg.addEventListener('click', () => { this.hideBackground(); this.minimizeAll(); }); }, 1); if (this.data[this.config.libraryName]) { // eslint-disable-next-line consistent-return _.forEach(this.data[this.config.libraryName], (movieData: Record) => { if (!this.maxCount || count < this.maxCount) { count += 1; this.content.appendChild(this.getMovieElement(movieData, this.data.serverID)); } else { return true; } }); } const endElem = document.createElement('div'); endElem.className = 'clear'; this.content.appendChild(endElem); this.calculatePositions(); this.loadCustomStyles(); }; calculatePositions = (): void => { // todo: figure out why interval is needed here and do it properly const setLeftOffsetsInterval = setInterval(() => { this.movieElems = this.getElementsByClassName('movieElem'); for (let i = 0; i < this.movieElems.length; i += 1) { if (this.movieElems[i].offsetLeft === 0) { break; } else { clearInterval(setLeftOffsetsInterval); } this.movieElems[i].style.left = `${this.movieElems[i].offsetLeft}px`; this.movieElems[i].dataset.left = this.movieElems[i].offsetLeft; this.movieElems[i].style.top = `${this.movieElems[i].offsetTop}px`; this.movieElems[i].dataset.top = this.movieElems[i].offsetTop; } }, 100); }; minimizeSeasons = (): void => { this.seasonsElemHidden = false; if (this.seasonsElem) { _.forEach(this.seasonsElem.childNodes, child => { const seasonElem = (child as HTMLElement).children[0] as HTMLElement; const seasonTitleElem = (child as HTMLElement).children[1] as HTMLElement; const seasonEpisodesCount = (child as HTMLElement).children[2] as HTMLElement; seasonElem.style.display = 'block'; const moveElem = (elem: HTMLElement): void => { const seasonElemLocal = elem; seasonElemLocal.style.marginTop = '0'; seasonElemLocal.style.width = `${CSS_STYLE.width}px`; seasonElemLocal.style.height = `${CSS_STYLE.height - 3}px`; seasonElemLocal.style.zIndex = '3'; seasonElemLocal.style.marginLeft = `0px`; seasonElemLocal.dataset.clicked = 'false'; seasonTitleElem.style.display = 'block'; seasonEpisodesCount.style.display = 'block'; setTimeout(() => { seasonTitleElem.style.color = 'rgba(255,255,255,1)'; seasonEpisodesCount.style.color = 'rgba(255,255,255,1)'; }, 500); }; if (seasonElem.dataset.clicked === 'true') { moveElem(seasonElem); } else { setTimeout(() => { moveElem(seasonElem); }, 100); } }); } }; minimizeAll = (): void => { this.activeMovieElem = undefined; for (let i = 0; i < this.movieElems.length; i += 1) { if (this.movieElems[i].dataset.clicked === 'true') { this.movieElems[i].style.width = `${CSS_STYLE.width}px`; this.movieElems[i].style.height = `${CSS_STYLE.height}px`; this.movieElems[i].style['z-index'] = 1; this.movieElems[i].style.position = 'absolute'; this.movieElems[i].style.left = `${this.movieElems[i].dataset.left}px`; this.movieElems[i].style.top = `${this.movieElems[i].dataset.top}px`; setTimeout(() => { this.movieElems[i].dataset.clicked = false; }, 500); } } this.hideSeasons(); this.hideEpisodes(); this.hideDetails(); }; hideSeasons = (): void => { if (this.seasonsElem) { this.seasonsElemHidden = true; const doc = document.documentElement; const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0); this.seasonsElem.style.top = `${top + 2000}px`; setTimeout(() => { if (this.seasonsElem && !this.seasonElemFreshlyLoaded) { this.seasonsElem.innerHTML = ''; this.seasonsElem.style.display = 'none'; this.resizeBackground(); } }, 700); } }; hideEpisodes = (): void => { if (this.episodesElem) { this.episodesElemHidden = true; const doc = document.documentElement; const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0); this.episodesElem.style.top = `${top + 2000}px`; setTimeout(() => { if (this.episodesElem && !this.episodesElemFreshlyLoaded) { this.episodesElem.innerHTML = ''; this.episodesElem.style.display = 'none'; this.resizeBackground(); } }, 700); } }; scrollDownInactiveSeasons = (): void => { if (this.seasonsElem) { this.seasonsElemHidden = true; _.forEach(this.seasonsElem.childNodes, child => { const seasonElem = (child as HTMLElement).children[0] as HTMLElement; const seasonTitleElem = (child as HTMLElement).children[1] as HTMLElement; const seasonEpisodesCount = (child as HTMLElement).children[2] as HTMLElement; if (seasonElem.dataset.clicked === 'false') { seasonElem.style.marginTop = '1000px'; seasonElem.style.marginLeft = `0px`; setTimeout(() => { seasonElem.style.display = 'none'; seasonTitleElem.style.display = 'none'; seasonEpisodesCount.style.display = 'none'; }, 500); } }); } }; hideDetails = (): void => { const doc = document.documentElement; const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0); if (this.detailElem) { this.detailElem.style.top = `${top - 1000}px`; this.detailElem.style.color = 'rgba(255,255,255,0)'; this.detailElem.style.zIndex = '0'; this.detailElem.style.visibility = 'hidden'; } }; showDetails = async (data: any): Promise => { const doc = document.documentElement; const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0); if (this.detailElem) { this.detailElem.style.transition = '0s'; this.detailElem.style.top = `${top - 1000}px`; setTimeout(() => { if (this.detailElem) { this.detailElem.style.visibility = 'visible'; this.detailElem.style.transition = '0.7s'; this.detailElem.style.top = `${top}px`; this.detailElem.children[0].innerHTML = escapeHtml(data.title); this.detailElem.children[1].innerHTML = escapeHtml(data.year); (this.detailElem.children[1] as HTMLElement).dataset.year = escapeHtml(data.year); this.detailElem.children[2].innerHTML = `${(data.duration !== undefined ? `${Math.round( parseInt(escapeHtml(data.duration), 10) / 60 / 1000 )} min` : '') + (data.contentRating !== undefined ? `${escapeHtml(data.contentRating)}` : '') + (data.rating !== undefined ? `${data.rating < 5 ? '🗑' : '⭐'} ${escapeHtml( data.rating )}` : '')}
`; this.detailElem.children[3].innerHTML = escapeHtml(data.summary); /* todo temp disabled if (data.type === 'movie') { (this.detailElem.children[5] as HTMLElement).style.visibility = 'visible'; this.detailElem.children[5].innerHTML = 'Play'; } else { (this.detailElem.children[5] as HTMLElement).style.visibility = 'hidden'; } */ this.detailElem.style.color = 'rgba(255,255,255,1)'; this.detailElem.style.zIndex = '4'; } }, 200); } if (this.plex && data.childCount > 0) { this.seasonElemFreshlyLoaded = true; const seasonsData = await this.plex.getLibraryData(data.key.split('/')[3]); if (this.seasonsElem) { this.seasonsElem.style.display = 'block'; this.seasonsElem.innerHTML = ''; this.seasonsElem.style.transition = `0s`; this.seasonsElem.style.top = `${top + 2000}px`; } _.forEach(seasonsData, seasonData => { if (this.seasonsElem) { this.seasonsElemHidden = false; const seasonContainer = document.createElement('div'); seasonContainer.className = 'seasonContainer'; seasonContainer.style.width = `${CSS_STYLE.width}px`; const thumbURL = `${this.plexProtocol}://${this.config.ip}:${this.config.port}/photo/:/transcode?width=${CSS_STYLE.expandedWidth}&height=${CSS_STYLE.expandedHeight}&minSize=1&upscale=1&url=${seasonData.thumb}&X-Plex-Token=${this.config.token}`; const seasonElem = document.createElement('div'); seasonElem.className = 'seasonElem'; seasonElem.style.width = `${CSS_STYLE.width}px`; seasonElem.style.height = `${CSS_STYLE.height - 3}px`; seasonElem.style.backgroundImage = `url('${thumbURL}')`; seasonElem.dataset.clicked = 'false'; const interactiveArea = document.createElement('div'); interactiveArea.className = 'interactiveArea'; const playButton = document.createElement('button'); playButton.name = 'playButton'; playButton.addEventListener('click', event => { event.stopPropagation(); if (this.plex && this.playController) { this.playController.play(seasonData.key.split('/')[3]); } }); interactiveArea.append(playButton); seasonElem.append(interactiveArea); seasonContainer.append(seasonElem); const seasonTitleElem = document.createElement('div'); seasonTitleElem.className = 'seasonTitleElem'; seasonTitleElem.innerHTML = escapeHtml(seasonData.title); seasonContainer.append(seasonTitleElem); const seasonEpisodesCount = document.createElement('div'); seasonEpisodesCount.className = 'seasonEpisodesCount'; seasonEpisodesCount.innerHTML = `${escapeHtml(seasonData.leafCount)} episodes`; seasonContainer.append(seasonEpisodesCount); seasonContainer.addEventListener('click', event => { event.stopPropagation(); if (this.activeMovieElem) { if (seasonElem.dataset.clicked === 'false') { seasonElem.dataset.clicked = 'true'; this.activeMovieElem.style.top = `${top - 1000}px`; this.scrollDownInactiveSeasons(); seasonContainer.style.top = `${-CSS_STYLE.expandedHeight}px`; seasonElem.style.width = `${CSS_STYLE.expandedWidth}px`; seasonElem.style.height = `${CSS_STYLE.expandedHeight - 6}px`; seasonElem.style.zIndex = '3'; seasonElem.style.marginLeft = `-${getOffset(seasonElem).left - getOffset(this.activeMovieElem).left}px`; seasonTitleElem.style.color = 'rgba(255,255,255,0)'; seasonEpisodesCount.style.color = 'rgba(255,255,255,0)'; if (this.detailElem) { (this.detailElem.children[1] as HTMLElement).innerHTML = seasonData.title; } (async (): Promise => { if (seasonData.leafCount > 0 && this.plex) { this.episodesElemFreshlyLoaded = true; const episodesData = await this.plex.getLibraryData(seasonData.key.split('/')[3]); if (this.episodesElem) { this.episodesElemHidden = false; this.episodesElem.style.display = 'block'; this.episodesElem.innerHTML = ''; this.episodesElem.style.transition = `0s`; this.episodesElem.style.top = `${top + 2000}px`; _.forEach(episodesData, episodeData => { if (this.episodesElem) { const episodeContainer = document.createElement('div'); episodeContainer.className = 'episodeContainer'; episodeContainer.style.width = `${CSS_STYLE.episodeWidth}px`; const episodeThumbURL = `${this.plexProtocol}://${this.config.ip}:${this.config.port}/photo/:/transcode?width=${CSS_STYLE.episodeWidth}&height=${CSS_STYLE.episodeHeight}&minSize=1&upscale=1&url=${episodeData.thumb}&X-Plex-Token=${this.config.token}`; const episodeElem = document.createElement('div'); episodeElem.className = 'episodeElem'; episodeElem.style.width = `${CSS_STYLE.episodeWidth}px`; episodeElem.style.height = `${CSS_STYLE.episodeHeight}px`; episodeElem.style.backgroundImage = `url('${episodeThumbURL}')`; episodeElem.dataset.clicked = 'false'; const episodeInteractiveArea = document.createElement('div'); episodeInteractiveArea.className = 'interactiveArea'; const episodePlayButton = document.createElement('button'); episodePlayButton.name = 'playButton'; episodePlayButton.addEventListener('click', episodeEvent => { episodeEvent.stopPropagation(); if (this.plex && this.playController) { this.playController.play(episodeData.key.split('/')[3], true); } }); episodeInteractiveArea.append(episodePlayButton); episodeElem.append(episodeInteractiveArea); episodeContainer.append(episodeElem); const episodeTitleElem = document.createElement('div'); episodeTitleElem.className = 'episodeTitleElem'; episodeTitleElem.innerHTML = escapeHtml(episodeData.title); episodeContainer.append(episodeTitleElem); const episodeNumber = document.createElement('div'); episodeNumber.className = 'episodeNumber'; episodeNumber.innerHTML = escapeHtml(`Episode ${escapeHtml(episodeData.index)}`); episodeContainer.append(episodeNumber); episodeContainer.addEventListener('click', episodeEvent => { episodeEvent.stopPropagation(); }); this.episodesElem.append(episodeContainer); } }); setTimeout(() => { if (this.episodesElem) { this.episodesElem.style.transition = `0.7s`; this.episodesElem.style.top = `${top + CSS_STYLE.expandedHeight + 16}px`; this.resizeBackground(); } }, 200); setTimeout(() => { this.episodesElemFreshlyLoaded = false; }, 700); } } })(); } else { seasonContainer.style.top = `${seasonContainer.dataset.top}px`; this.minimizeSeasons(); this.hideEpisodes(); this.activeMovieElem.style.top = `${top + 16}px`; if (this.detailElem && (this.detailElem.children[1] as HTMLElement)) { const { year } = (this.detailElem.children[1] as HTMLElement).dataset; if (year) { (this.detailElem.children[1] as HTMLElement).innerHTML = year; } } } } }); this.seasonsElem.append(seasonContainer); } }); _.forEach((this.seasonsElem as HTMLElement).children, elem => { const seasonElem = elem as HTMLElement; const left = seasonElem.offsetLeft; const topElem = seasonElem.offsetTop; seasonElem.style.left = `${left}px`; seasonElem.dataset.left = `${left}`; seasonElem.style.top = `${topElem}px`; seasonElem.dataset.top = `${topElem}`; }); _.forEach((this.seasonsElem as HTMLElement).children, elem => { const seasonElem = elem as HTMLElement; seasonElem.style.position = 'absolute'; }); setTimeout(() => { this.seasonElemFreshlyLoaded = false; }, 700); setTimeout(() => { if (this.seasonsElem) { this.seasonsElem.style.transition = `0.7s`; this.seasonsElem.style.top = `${top + CSS_STYLE.expandedHeight + 16}px`; this.resizeBackground(); } }, 200); } }; resizeBackground = (): void => { if (this.seasonsElem && this.episodesElem) { const contentbg = this.getElementsByClassName('contentbg'); if (this.contentBGHeight === 0) { this.contentBGHeight = (contentbg[0] as HTMLElement).scrollHeight; } const requiredSeasonBodyHeight = parseInt(this.seasonsElem.style.top.replace('px', ''), 10) + this.seasonsElem.scrollHeight; const requiredEpisodeBodyHeight = parseInt(this.episodesElem.style.top.replace('px', ''), 10) + this.episodesElem.scrollHeight; if (requiredSeasonBodyHeight > this.contentBGHeight && !this.seasonsElemHidden) { (contentbg[0] as HTMLElement).style.height = `${requiredSeasonBodyHeight}px`; } else if (requiredEpisodeBodyHeight > this.contentBGHeight && !this.episodesElemHidden) { (contentbg[0] as HTMLElement).style.height = `${requiredEpisodeBodyHeight}px`; } else { (contentbg[0] as HTMLElement).style.height = '100%'; } } }; showBackground = (): void => { const contentbg = this.getElementsByClassName('contentbg'); (contentbg[0] as HTMLElement).style.zIndex = '2'; (contentbg[0] as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.9)'; }; hideBackground = (): void => { const contentbg = this.getElementsByClassName('contentbg'); (contentbg[0] as HTMLElement).style.zIndex = '1'; (contentbg[0] as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0)'; }; activateMovieElem = (movieElem: HTMLElement): void => { const movieElemLocal = movieElem; if (movieElem.dataset.clicked === 'true') { this.minimizeAll(); this.activeMovieElem = undefined; this.hideDetails(); movieElemLocal.style.width = `${CSS_STYLE.width}px`; movieElemLocal.style.height = `${CSS_STYLE.height}px`; movieElemLocal.style.zIndex = '1'; movieElemLocal.style.top = `${movieElem.dataset.top}px`; movieElemLocal.style.left = `${movieElem.dataset.left}px`; setTimeout(() => { movieElemLocal.dataset.clicked = 'false'; }, 500); this.hideBackground(); } else { const doc = document.documentElement; const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0); this.minimizeAll(); this.showDetails(this.activeMovieElemData); this.showBackground(); movieElemLocal.style.width = `${CSS_STYLE.expandedWidth}px`; movieElemLocal.style.height = `${CSS_STYLE.expandedHeight}px`; movieElemLocal.style.zIndex = '3'; movieElemLocal.style.left = '16px'; movieElemLocal.style.top = `${top + 16}px`; movieElemLocal.dataset.clicked = 'true'; this.activeMovieElem = movieElemLocal; } }; getMovieElement = (data: any, serverID: string): HTMLDivElement => { const thumbURL = `${this.plexProtocol}://${this.config.ip}:${this.config.port}/photo/:/transcode?width=${CSS_STYLE.expandedWidth}&height=${CSS_STYLE.expandedHeight}&minSize=1&upscale=1&url=${data.thumb}&X-Plex-Token=${this.config.token}`; const container = document.createElement('div'); container.className = 'container'; container.style.width = `${CSS_STYLE.width}px`; container.style.height = `${CSS_STYLE.height + 30}px`; const movieElem = document.createElement('div'); movieElem.className = 'movieElem'; movieElem.style.width = `${CSS_STYLE.width}px`; movieElem.style.height = `${CSS_STYLE.height}px`; movieElem.style.backgroundImage = `url('${thumbURL}')`; if (!this.playSupported) { movieElem.style.cursor = 'pointer'; } movieElem.addEventListener('click', () => { this.activeMovieElemData = data; this.activateMovieElem(movieElem); }); const playButton = this.getPlayButton(); const interactiveArea = document.createElement('div'); interactiveArea.className = 'interactiveArea'; if (this.playSupported) { interactiveArea.append(playButton); } movieElem.append(interactiveArea); playButton.addEventListener('click', event => { event.stopPropagation(); const keyParts = data.key.split('/'); const movieID = keyParts[3]; if (this.hassObj && this.playController) { this.playController.play(movieID, data.type === 'movie'); } }); const titleElem = document.createElement('div'); titleElem.innerHTML = escapeHtml(data.title); titleElem.className = 'titleElem'; titleElem.style.marginTop = `${CSS_STYLE.height}px`; const yearElem = document.createElement('div'); yearElem.innerHTML = escapeHtml(data.year); yearElem.className = 'yearElem'; container.appendChild(movieElem); container.appendChild(titleElem); container.appendChild(yearElem); return container; }; loadCustomStyles = (): void => { this.appendChild(style); }; getPlayButton = (): HTMLButtonElement => { const playButton = document.createElement('button'); playButton.name = 'playButton'; return playButton; }; setConfig = (config: any): void => { this.plexProtocol = 'http'; if (!config.entity_id) { throw new Error('You need to define an entity_id'); } if (!config.token) { throw new Error('You need to define a token'); } if (!config.ip) { throw new Error('You need to define a ip'); } if (!config.port) { throw new Error('You need to define a port'); } if (!config.libraryName) { throw new Error('You need to define a libraryName'); } this.config = config; if (config.protocol) { this.plexProtocol = config.protocol; } if (config.maxCount) { this.maxCount = config.maxCount; } this.plex = new Plex(this.config.ip, this.config.port, this.config.token, this.plexProtocol); }; getCardSize = (): number => { return 3; }; } customElements.define('plex-meets-homeassistant', PlexMeetsHomeAssistant);