Skip to content

Commit

Permalink
feature: use ha built in media browser
Browse files Browse the repository at this point in the history
It has become an increasingly hard task to try and maintain a self-written media-browser and try to match what Home Assistant's built-in media browser can do. This area has been the one with the most bugs and issues reported. Because of this the decision is to only show favorites in media section, and for other media the user can use HA's built-in media browser dialog.
  • Loading branch information
punxaphil committed Dec 3, 2023
1 parent a105964 commit 7de8e8c
Show file tree
Hide file tree
Showing 9 changed files with 70 additions and 202 deletions.
57 changes: 17 additions & 40 deletions src/components/media-browser-header.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,36 @@
import { css, html, LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import { CardConfig } from '../types';
import { mdiArrowUpLeftBold, mdiPlay, mdiPlayBoxMultiple, mdiStarOutline } from '@mdi/js';
import { BROWSE_CLICKED, BROWSE_STATE, PLAY_DIR } from '../constants';
import { iconButton } from './icon-button';
import { property } from 'lit/decorators.js';
import { MediaPlayerEntityFeature } from '../types';
import Store from '../model/store';
import { styleMap } from 'lit-html/directives/style-map.js';

class MediaBrowserHeader extends LitElement {
@property() config!: CardConfig;
@state() browseCanPlay!: boolean;
@state() browseMedia = true;
@state() mediaBrowserDir!: string;
@state() title!: string;
@property() store!: Store;

render() {
const browseIcon = this.browseMedia
? mdiPlayBoxMultiple
: this.mediaBrowserDir
? mdiArrowUpLeftBold
: mdiStarOutline;
const state = this.store.hass.states[this.store.activePlayer.id];
const playerState = {
...state,
attributes: { ...state.attributes, supported_features: MediaPlayerEntityFeature.BROWSE_MEDIA },
};
return html`
<div class="play">
${this.browseCanPlay
? iconButton(mdiPlay, () => window.dispatchEvent(new CustomEvent(PLAY_DIR)), {
additionalStyle: { padding: '0.5rem' },
})
: ''}
</div>
<div class="title">${this.title}</div>
${iconButton(browseIcon, () => window.dispatchEvent(new CustomEvent(BROWSE_CLICKED)), {
additionalStyle: { padding: '0.5rem', flex: '1', textAlign: 'right' },
})}
<div class="title">All Favorites</div>
<more-info-content
.stateObj=${playerState}
.hass=${this.store.hass}
style=${styleMap({ padding: '0.5rem', flex: '1', textAlign: 'right' })}
></more-info-content>
`;
}

connectedCallback() {
super.connectedCallback();
window.addEventListener(BROWSE_STATE, (event: Event) => {
const detail = (event as CustomEvent).detail;
this.browseCanPlay = detail.canPlay;
this.browseMedia = !detail.browse;
this.mediaBrowserDir = detail.currentDir;
this.title = detail.title;
});
}
static get styles() {
return css`
:host {
display: flex;
justify-content: space-between;
}
.play {
flex: 1;
}
.title {
flex: 6;
flex: 1;
text-align: center;
font-size: 1.2rem;
font-weight: bold;
Expand Down
18 changes: 6 additions & 12 deletions src/components/media-browser-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,12 @@ export class MediaBrowserIcons extends LitElement {
</style>
<div class="icons">
${itemsWithFallbacks(this.items, this.config).map(
(item, index) =>
html`
${mediaItemBackgroundImageStyle(item.thumbnail, index)}
<ha-control-button class="button" @click="${() => dispatchMediaItemSelected(item)}">
${renderMediaBrowserItem(item, !item.thumbnail || !!this.config.mediaBrowserShowTitleForThumbnailIcons)}
</ha-control-button>
`,
(item, index) => html`
${mediaItemBackgroundImageStyle(item.thumbnail, index)}
<ha-control-button class="button" @click="${() => dispatchMediaItemSelected(item)}">
${renderMediaBrowserItem(item, !item.thumbnail || !!this.config.mediaBrowserShowTitleForThumbnailIcons)}
</ha-control-button>
`,
)}
</div>
`;
Expand Down Expand Up @@ -63,11 +62,6 @@ export class MediaBrowserIcons extends LitElement {
background-position: center;
}
.folder {
margin: 5% 15% 25% 15%;
--mdc-icon-size: 100%;
}
.title {
font-size: 0.8rem;
position: absolute;
Expand Down
5 changes: 0 additions & 5 deletions src/components/media-browser-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,6 @@ export class MediaBrowserList extends LitElement {
background-position: left;
}
.folder {
width: var(--icon-width);
--mdc-icon-size: 100%;
}
.title {
font-size: 1.1rem;
align-self: center;
Expand Down
3 changes: 0 additions & 3 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@ export const REQUEST_PLAYER_EVENT = dispatchPrefix + 'request-player';
export const SHOW_SECTION = dispatchPrefix + 'show-section';
export const CALL_MEDIA_STARTED = dispatchPrefix + 'call-media-started';
export const CALL_MEDIA_DONE = dispatchPrefix + 'call-media-done';
export const PLAY_DIR = dispatchPrefix + 'play-dir';
export const BROWSE_CLICKED = dispatchPrefix + 'browse-up';
export const BROWSE_STATE = dispatchPrefix + 'browse-state';
export const MEDIA_ITEM_SELECTED = dispatchPrefix + 'media-item-selected';

export const TV_BASE64_IMAGE =
Expand Down
7 changes: 1 addition & 6 deletions src/model/media-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ export class MediaPlayer {
name: string;
state: string;
members: MediaPlayer[];
attributes: {
[key: string]: any;
};
attributes: HassEntity['attributes'];
private readonly config: CardConfig;

constructor(hassEntity: HassEntity, config: CardConfig, mediaPlayerHassEntities?: HassEntity[]) {
Expand All @@ -21,9 +19,6 @@ export class MediaPlayer {
this.members = mediaPlayerHassEntities ? this.createGroupMembers(hassEntity, mediaPlayerHassEntities) : [];
}

hasPlayer(playerId: string) {
return this.getPlayer(playerId) !== undefined;
}
getPlayer(playerId: string) {
return this.id === playerId ? this : this.getMember(playerId);
}
Expand Down
104 changes: 10 additions & 94 deletions src/sections/media-browser.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,32 @@
import { html, LitElement } from 'lit';
import { until } from 'lit-html/directives/until.js';
import { property, state } from 'lit/decorators.js';
import { property } from 'lit/decorators.js';
import '../components/media-browser-list';
import '../components/media-browser-icons';
import '../components/media-browser-header';
import MediaBrowseService from '../services/media-browse-service';
import MediaControlService from '../services/media-control-service';
import Store from '../model/store';
import { CardConfig, MediaPlayerItem, Section } from '../types';
import { dispatchShowSection } from '../utils/utils';
import { BROWSE_CLICKED, BROWSE_STATE, MEDIA_ITEM_SELECTED, PLAY_DIR } from '../constants';
import { MEDIA_ITEM_SELECTED } from '../constants';
import { MediaPlayer } from '../model/media-player';
import { until } from 'lit-html/directives/until.js';
import MediaBrowseService from '../services/media-browse-service';
import { indexOfWithoutSpecialChars } from '../utils/media-browser-utils';

const LOCAL_STORAGE_CURRENT_DIR = 'custom-sonos-card_currentDir';
const LOCAL_STORAGE_BROWSE = 'custom-sonos-card_browse';

export class MediaBrowser extends LitElement {
@property() store!: Store;
private config!: CardConfig;
private activePlayer!: MediaPlayer;
@state() private browse!: boolean;
@state() private currentDir?: MediaPlayerItem;
private mediaPlayers!: MediaPlayer[];
private parentDirs: MediaPlayerItem[] = [];
private mediaControlService!: MediaControlService;
private mediaBrowseService!: MediaBrowseService;

private readonly playDirListener = async () => {
await this.playItem(<MediaPlayerItem>this.currentDir);
};

private readonly browseClickedListener = async () => {
this.browseClicked();
};

connectedCallback() {
super.connectedCallback();
window.addEventListener(PLAY_DIR, this.playDirListener);
window.addEventListener(BROWSE_CLICKED, this.browseClickedListener);
window.addEventListener(MEDIA_ITEM_SELECTED, this.onMediaItemSelected);
}

disconnectedCallback() {
window.removeEventListener(PLAY_DIR, this.playDirListener);
window.removeEventListener(BROWSE_CLICKED, this.browseClickedListener);
window.removeEventListener(MEDIA_ITEM_SELECTED, this.onMediaItemSelected);
super.disconnectedCallback();
}
Expand All @@ -56,84 +38,24 @@ export class MediaBrowser extends LitElement {
this.mediaPlayers = this.store.allMediaPlayers;
this.mediaControlService = this.store.mediaControlService;

const currentDirJson = localStorage.getItem(LOCAL_STORAGE_CURRENT_DIR);
if (currentDirJson) {
const currentDir = JSON.parse(currentDirJson);
if (currentDir !== this.currentDir) {
this.currentDir = currentDir;
this.browse = true;
this.dispatchBrowseState();
}
} else {
this.browse = !!localStorage.getItem(LOCAL_STORAGE_BROWSE);
}
return html`
<sonos-media-browser-header .config=${this.config}></sonos-media-browser-header>
<sonos-media-browser-header .store=${this.store}></sonos-media-browser-header>
${this.activePlayer &&
until(
(this.browse ? this.loadMediaDir(this.currentDir) : this.getAllFavorites()).then((items) => {
return this.config.mediaBrowserItemsPerRow > 1 && this.currentDir?.children_media_class !== 'track'
this.getAllFavorites().then((items) => {
return this.config.mediaBrowserItemsPerRow > 1
? html`<sonos-media-browser-icons .items=${items} .store=${this.store}></sonos-media-browser-icons>`
: html` <sonos-media-browser-list .items=${items} .store=${this.store}></sonos-media-browser-list>`;
}),
)}
`;
}

firstUpdated() {
this.dispatchBrowseState();
}

private dispatchBrowseState() {
const title = !this.browse ? 'All Favorites' : this.currentDir ? this.currentDir.title : 'Media Browser';
window.dispatchEvent(
new CustomEvent(BROWSE_STATE, {
detail: {
canPlay: this.currentDir?.can_play,
browse: this.browse,
currentDir: this.currentDir,
title,
},
}),
);
}

private browseClicked() {
if (this.parentDirs.length) {
this.setCurrentDir(this.parentDirs.pop());
} else if (this.currentDir) {
this.setCurrentDir(undefined);
} else {
this.browse = !this.browse;

if (this.browse) {
localStorage.setItem(LOCAL_STORAGE_BROWSE, 'true');
} else {
localStorage.removeItem(LOCAL_STORAGE_BROWSE);
}
this.dispatchBrowseState();
}
}

private setCurrentDir(mediaItem?: MediaPlayerItem) {
this.currentDir = mediaItem;
if (mediaItem) {
localStorage.setItem(LOCAL_STORAGE_CURRENT_DIR, JSON.stringify(mediaItem));
} else {
localStorage.removeItem(LOCAL_STORAGE_CURRENT_DIR);
}
this.dispatchBrowseState();
}

private onMediaItemSelected = (event: Event) => {
const mediaItem = (event as CustomEvent).detail;
if (mediaItem.can_expand) {
this.currentDir && this.parentDirs.push(this.currentDir);
this.setCurrentDir(mediaItem);
} else if (mediaItem.can_play) {
this.playItem(mediaItem);
setTimeout(() => dispatchShowSection(Section.PLAYER), 1000);
}
this.playItem(mediaItem);
setTimeout(() => dispatchShowSection(Section.PLAYER), 1000);
};

private async playItem(mediaItem: MediaPlayerItem) {
Expand Down Expand Up @@ -179,10 +101,4 @@ export class MediaBrowser extends LitElement {
private static createSource(source: MediaPlayerItem) {
return { ...source, can_play: true };
}

private async loadMediaDir(mediaItem?: MediaPlayerItem) {
return await (mediaItem
? this.mediaBrowseService.getDir(this.activePlayer, mediaItem, this.config.mediaBrowserTitlesToIgnore)
: this.mediaBrowseService.getRoot(this.activePlayer, this.config.mediaBrowserTitlesToIgnore));
}
}
34 changes: 5 additions & 29 deletions src/services/media-browse-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,22 @@ import HassService from './hass-service';
import { MediaPlayer } from '../model/media-player';
import { indexOfWithoutSpecialChars } from '../utils/media-browser-utils';

function mediaBrowserFilter(ignoredTitles: string[] = [], items?: MediaPlayerItem[]) {
return items?.filter(
(item) =>
!['media-source://tts', 'media-source://camera'].includes(item.media_content_id || '') &&
indexOfWithoutSpecialChars(ignoredTitles, item.title) === -1,
);
}

export default class MediaBrowseService {
private hassService: HassService;

constructor(hassService: HassService) {
this.hassService = hassService;
}

async getRoot(mediaPlayer: MediaPlayer, ignoredTitles?: string[]): Promise<MediaPlayerItem[]> {
const root = await this.hassService.browseMedia(mediaPlayer);
return mediaBrowserFilter(ignoredTitles, root.children) || [];
}

async getDir(mediaPlayer: MediaPlayer, dir: MediaPlayerItem, ignoredTitles?: string[]): Promise<MediaPlayerItem[]> {
try {
const dirItem = await this.hassService.browseMedia(mediaPlayer, dir.media_content_type, dir.media_content_id);
return mediaBrowserFilter(ignoredTitles, dirItem.children) || [];
} catch (e) {
console.error(e);
return [];
}
}

async getAllFavorites(mediaPlayers: MediaPlayer[], ignoredTitles?: string[]): Promise<MediaPlayerItem[]> {
if (!mediaPlayers.length) {
return [];
}
const favoritesForAllPlayers = await Promise.all(
mediaPlayers.map((player) => this.getFavoritesForPlayer(player, ignoredTitles)),
);
const favoritesForAllPlayers = await Promise.all(mediaPlayers.map((player) => this.getFavoritesForPlayer(player)));
let favorites = favoritesForAllPlayers.flatMap((f) => f);
favorites = this.removeDuplicates(favorites);
return favorites.length ? favorites : this.getFavoritesFromStates(mediaPlayers);
favorites = favorites.length ? favorites : this.getFavoritesFromStates(mediaPlayers);
return favorites.filter((item) => indexOfWithoutSpecialChars(ignoredTitles ?? [], item.title) === -1);
}

private removeDuplicates(items: MediaPlayerItem[]) {
Expand All @@ -51,13 +27,13 @@ export default class MediaBrowseService {
});
}

private async getFavoritesForPlayer(player: MediaPlayer, ignoredTitles?: string[]) {
private async getFavoritesForPlayer(player: MediaPlayer) {
const favoritesRoot = await this.hassService.browseMedia(player, 'favorites', '');
const favoriteTypesPromise = favoritesRoot.children?.map((favoriteItem) =>
this.hassService.browseMedia(player, favoriteItem.media_content_type, favoriteItem.media_content_id),
);
const favoriteTypes = favoriteTypesPromise ? await Promise.all(favoriteTypesPromise) : [];
return favoriteTypes.flatMap((item) => mediaBrowserFilter(ignoredTitles, item.children) || []);
return favoriteTypes.flatMap((item) => item.children ?? []);
}

private getFavoritesFromStates(mediaPlayers: MediaPlayer[]) {
Expand Down
Loading

0 comments on commit 7de8e8c

Please sign in to comment.