diff --git a/CHANGELOG.md b/CHANGELOG.md index e2149c2..f124040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.10 ] - 2024/11/03 + + * This release requires the SpotifyPlus v1.0.64 release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. + * Added `footerIconSize` general config option to change the size of the footer area icons. + * Added `playerControlsIconSize` player controls config option to change the size of the player control area icons, volume mute icon, and power on/off icons. + * Added actions dropdown menu to all section favorites browser details; most of these are the ability to search for related details. More actions to come in future releases. + * Added actions dropdown menu to all player information details; most of these are the ability to search for related details. More actions to come in future releases. + * Added ability to copy device details to the clipboard; for example, click on the value next to the `Device ID` title to copy the device id to the clipboard. + * Added (all browsers) action: copy Spotify URI to clipboard. + * Added playlist action: recover playlists via Spotify web ui. + * Added playlist action: delete (unfollow) a playlist. + * Updated playlist item action: track will now be added to the play queue instead of being played. This will avoid wiping out the play queue. + * Updated album item action: track will now be added to the play queue instead of being played. This will avoid wiping out the play queue. + * Updated podcast show item action: episode will now be added to the play queue instead of being played. This will avoid wiping out the play queue. + * Updated audiobook item action: chapter will now be added to the play queue instead of being played. This will avoid wiping out the play queue. + ###### [ 1.0.9 ] - 2024/10/30 * This release requires the SpotifyPlus v1.0.63 release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. diff --git a/SpotifyPlusCard.njsproj b/SpotifyPlusCard.njsproj index 0ab59b1..a222fcc 100644 --- a/SpotifyPlusCard.njsproj +++ b/SpotifyPlusCard.njsproj @@ -141,6 +141,9 @@ + + Code + diff --git a/package-lock.json b/package-lock.json index 452589b..e72985f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@vibrant/image-browser": "^3.2.1-alpha.1", "@vibrant/image-node": "^3.2.1-alpha.1", "@vibrant/quantizer-mmcq": "^3.2.1-alpha.1", + "copy-text-to-clipboard": "^3.2.0", "custom-card-helpers": "^1.9.0", "debug": "^4.3.7", "lit": "^2.8.0", @@ -3663,6 +3664,17 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/copy-text-to-clipboard": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.0.tgz", + "integrity": "sha512-RnJFp1XR/LOBDckxTib5Qjr/PMfkatD0MUCQgdpqS8MdKiNUzBjAQBEN6oUy+jW7LI93BBG3DtMB2KOOKpGs2Q==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", diff --git a/package.json b/package.json index 54c09e1..388f1b4 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@vibrant/image-browser": "^3.2.1-alpha.1", "@vibrant/image-node": "^3.2.1-alpha.1", "@vibrant/quantizer-mmcq": "^3.2.1-alpha.1", + "copy-text-to-clipboard": "^3.2.0", "custom-card-helpers": "^1.9.0", "debug": "^4.3.7", "lit": "^2.8.0", diff --git a/src/card.ts b/src/card.ts index 0f59170..30548c7 100644 --- a/src/card.ts +++ b/src/card.ts @@ -2,7 +2,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import { css, html, LitElement, PropertyValues, TemplateResult } from 'lit'; import { styleMap } from 'lit-html/directives/style-map.js'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property, query, state } from 'lit/decorators.js'; import { choose } from 'lit/directives/choose.js'; import { when } from 'lit/directives/when.js'; @@ -23,16 +23,19 @@ import './components/footer'; import './editor/editor'; // our imports. +import { SEARCH_MEDIA, SearchMediaEventArgs } from './events/search-media'; import { EDITOR_CONFIG_AREA_SELECTED, EditorConfigAreaSelectedEventArgs } from './events/editor-config-area-selected'; import { PROGRESS_STARTED } from './events/progress-started'; import { PROGRESS_ENDED } from './events/progress-ended'; import { Store } from './model/store'; +import { Section } from './types/section'; +import { ConfigArea } from './types/config-area'; import { CardConfig } from './types/card-config'; import { CustomImageUrls } from './types/custom-image-urls'; -import { ConfigArea } from './types/config-area'; -import { Section } from './types/section'; +import { SearchMediaTypes } from './types/search-media-types'; +import { SearchBrowser } from './sections/search-media-browser'; import { formatTitleInfo, removeSpecialChars } from './utils/media-browser-utils'; -import { BRAND_LOGO_IMAGE_BASE64, BRAND_LOGO_IMAGE_SIZE } from './constants'; +import { BRAND_LOGO_IMAGE_BASE64, BRAND_LOGO_IMAGE_SIZE, FOOTER_ICON_SIZE_DEFAULT } from './constants'; import { getConfigAreaForSection, getSectionForConfigArea, @@ -41,7 +44,11 @@ import { isCardInPickerPreview, isNumber, } from './utils/utils'; -import { SearchMediaTypes } from './types/search-media-types'; + +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from './constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":card"); const HEADER_HEIGHT = 2; const FOOTER_HEIGHT = 4; @@ -88,6 +95,9 @@ export class Card extends LitElement { @state() private cancelLoader!: boolean; @state() private playerId!: string; + @query("#elmSearchMediaBrowserForm", false) private elmSearchMediaBrowserForm!: SearchBrowser; + + /** Indicates if createStore method is executing for the first time (true) or not (false). */ private isFirstTimeSetup: boolean = true; @@ -163,7 +173,7 @@ export class Card extends LitElement { [Section.PLAYER, () => html``], [Section.PLAYLIST_FAVORITES, () => html``], [Section.RECENTS, () => html``], - [Section.SEARCH_MEDIA, () => html``], + [Section.SEARCH_MEDIA, () => html``], [Section.SHOW_FAVORITES, () => html``], [Section.TRACK_FAVORITES, () => html``], [Section.USERPRESETS, () => html``], @@ -274,9 +284,10 @@ export class Card extends LitElement { align-self: flex-start; align-items: center; justify-content: space-around; + flex-wrap: wrap; width: 100%; - --mdc-icon-size: 1.75rem; - --mdc-icon-button-size: 2.5rem; + --mdc-icon-button-size: var(--spc-footer-icon-button-size, 2.5rem); + --mdc-icon-size: var(--spc-footer-icon-size, 1.75rem); --mdc-ripple-top: 0px; --mdc-ripple-left: 0px; --mdc-ripple-fg-size: 10px; @@ -395,9 +406,10 @@ export class Card extends LitElement { // invoke base class method. super.connectedCallback(); - // add control level event listeners. + // add card level event listeners. this.addEventListener(PROGRESS_ENDED, this.onProgressEndedEventHandler); this.addEventListener(PROGRESS_STARTED, this.onProgressStartedEventHandler); + this.addEventListener(SEARCH_MEDIA, this.onSearchMediaEventHandler); // only add the following events if card configuration is being edited. if (isCardInEditPreview(this)) { @@ -422,9 +434,10 @@ export class Card extends LitElement { */ public disconnectedCallback() { - // remove control level event listeners. + // remove card level event listeners. this.removeEventListener(PROGRESS_ENDED, this.onProgressEndedEventHandler); this.removeEventListener(PROGRESS_STARTED, this.onProgressStartedEventHandler); + this.removeEventListener(SEARCH_MEDIA, this.onSearchMediaEventHandler); // the following event is only added when the card configuration editor is created. // always remove the following events, as isCardInEditPreview() can sometimes @@ -507,6 +520,75 @@ export class Card extends LitElement { } + /** + * Handles the card configuration editor `EDITOR_CONFIG_AREA_SELECTED` event. + * + * This will select a section for display / rendering. + * This event should only be fired from the configuration editor instance. + * + * @param ev Event definition and arguments. + */ + protected onEditorConfigAreaSelectedEventHandler = (ev: Event) => { + + // map event arguments. + const evArgs = (ev as CustomEvent).detail as EditorConfigAreaSelectedEventArgs; + + // is section activated? if so, then select it. + if (this.config.sections?.includes(evArgs.section)) { + + this.section = evArgs.section; + this.store.section = this.section; + + } else { + + // section is not activated. + + } + } + + + /** + * Handles the footer `show-section` event. + * + * This will change the `section` attribute value to the value supplied, which will also force + * a refresh of the card and display the selected section. + * + * @param args Event arguments that contain the section to show. + */ + protected onFooterShowSection = (args: CustomEvent) => { + + const section = args.detail; + if (!this.config.sections || this.config.sections.indexOf(section) > -1) { + + this.section = section; + this.store.section = this.section; + super.requestUpdate(); + + } else { + + // specified section is not active. + + } + } + + + /** + * Handles the Media List `item-selected` event. + * + * @param args Event arguments (none passed). + */ + protected onMediaListItemSelected = () => { + + // don't need to do anything here, as the section will show the player. + // left this code here though, in case we want to do something else after + // an item is selected. + + // example: show the card Player section (after a slight delay). + //setTimeout(() => (this.SetSection(Section.PLAYER)), 1500); + + } + + /** * Handles the `PROGRESS_ENDED` event. * This will hide the circular progress indicator on the main card display. @@ -566,74 +648,49 @@ export class Card extends LitElement { /** - * Handles the card configuration editor `EDITOR_CONFIG_AREA_SELECTED` event. - * - * This will select a section for display / rendering. - * This event should only be fired from the configuration editor instance. + * Handles the `SEARCH_MEDIA` event. + * This will execute a search on the specified criteria passed in the event arguments. * * @param ev Event definition and arguments. */ - protected onEditorConfigAreaSelectedEventHandler = (ev: Event) => { + protected onSearchMediaEventHandler = (ev: Event) => { // map event arguments. - const evArgs = (ev as CustomEvent).detail as EditorConfigAreaSelectedEventArgs; + const evArgs = (ev as CustomEvent).detail as SearchMediaEventArgs; // is section activated? if so, then select it. - if (this.config.sections?.includes(evArgs.section)) { + if (this.config.sections?.includes(Section.SEARCH_MEDIA)) { - this.section = evArgs.section; + // show the search section. + this.section = Section.SEARCH_MEDIA; this.store.section = this.section; + //this.dispatchEvent(customEvent(SHOW_SECTION, Section.SEARCH_MEDIA)); - } else { - - // section is not activated. - - } - } - + // wait just a bit before executing the search. + setTimeout(() => { - /** - * Handles the footer `show-section` event. - * - * This will change the `section` attribute value to the value supplied, which will also force - * a refresh of the card and display the selected section. - * - * @param args Event arguments that contain the section to show. - */ - protected onFooterShowSection = (args: CustomEvent) => { + if (debuglog.enabled) { + debuglog("onSearchMediaEventHandler - executing search:\n%s", + JSON.stringify(evArgs, null, 2), + ); + } - const section = args.detail; - if (!this.config.sections || this.config.sections.indexOf(section) > -1) { + // execute the search. + this.elmSearchMediaBrowserForm.searchExecute(evArgs); - this.section = section; - this.store.section = this.section; - super.requestUpdate(); + }, 250); } else { - // specified section is not active. + // section is not activated; cannot search. + debuglog("onSearchMediaEventHandler - Search section is not enabled; ignoring search request:\n%s", + JSON.stringify(evArgs, null, 2), + ); } } - /** - * Handles the Media List `item-selected` event. - * - * @param args Event arguments (none passed). - */ - protected onMediaListItemSelected = () => { - - // don't need to do anything here, as the section will show the player. - // left this code here though, in case we want to do something else after - // an item is selected. - - // example: show the card Player section (after a slight delay). - //setTimeout(() => (this.SetSection(Section.PLAYER)), 1500); - - } - - /** * Home Assistant will call setConfig(config) when the configuration changes. This * is most likely to occur when changing the configuration via the UI editor, but @@ -1036,11 +1093,16 @@ export class Card extends LitElement { */ private styleCardFooter() { + // set footer icon size. + const footerIconSize = this.config.footerIconSize || FOOTER_ICON_SIZE_DEFAULT; + // is player selected, and a footer background color set? if ((this.section == Section.PLAYER) && (this.footerBackgroundColor)) { // yes - return vibrant background style. return styleMap({ + '--spc-footer-icon-size': `${footerIconSize}`, + '--spc-footer-icon-button-size': `var(--spc-footer-icon-size, ${FOOTER_ICON_SIZE_DEFAULT}) + 0.75rem`, '--spc-player-footer-bg-color': `${this.footerBackgroundColor || 'transparent'}`, 'background-color': 'var(--spc-player-footer-bg-color)', 'background-image': 'linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 1.6))', @@ -1048,8 +1110,10 @@ export class Card extends LitElement { } else { - // no - just return an empty style to let it default to the card background. + // return style map (let background color default to the card background color). return styleMap({ + '--spc-footer-icon-size': `${footerIconSize}`, + '--spc-footer-icon-button-size': `var(--spc-footer-icon-size, ${FOOTER_ICON_SIZE_DEFAULT}) + 0.75rem`, }); } diff --git a/src/components/album-actions.ts b/src/components/album-actions.ts index eccf13c..ce6a8ff 100644 --- a/src/components/album-actions.ts +++ b/src/components/album-actions.ts @@ -1,12 +1,17 @@ // lovelace card imports. import { css, html, TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; +import copyTextToClipboard from 'copy-text-to-clipboard'; import { mdiAccountMusic, mdiAlbum, + mdiClipboardPlusOutline, + mdiDotsHorizontal, mdiHeart, mdiHeartOutline, - mdiPlay, + mdiMusic, + mdiPlaylistPlay, + mdiRadio, } from '@mdi/js'; // our imports. @@ -16,24 +21,34 @@ import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; import { FavActionsBase } from './fav-actions-base'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; +import { SearchMediaTypes } from '../types/search-media-types'; +import { SearchMediaEvent } from '../events/search-media'; import { getIdFromSpotifyUri } from '../services/spotifyplus-service'; import { formatDateHHMMSSFromMilliseconds } from '../utils/utils'; +import { openWindowNewTab } from '../utils/media-browser-utils'; +import { RADIO_SEARCH_KEY } from '../constants'; +import { GetCopyrights } from '../types/spotifyplus/copyright'; import { IAlbum } from '../types/spotifyplus/album'; import { ITrackPageSimplified } from '../types/spotifyplus/track-page-simplified'; -import { GetCopyrights } from '../types/spotifyplus/copyright'; -import { openWindowNewTab } from '../utils/media-browser-utils'; /** * Album actions. */ enum Actions { + AlbumCopyUriToClipboard = "AlbumCopyUriToClipboard", AlbumFavoriteAdd = "AlbumFavoriteAdd", AlbumFavoriteRemove = "AlbumFavoriteRemove", AlbumFavoriteUpdate = "AlbumFavoriteUpdate", + AlbumTrackQueueAdd = "AlbumTrackQueueAdd", AlbumTracksUpdate = "AlbumTracksUpdate", + AlbumSearchRadio = "AlbumSearchRadio", + ArtistCopyUriToClipboard = "ArtistCopyUriToClipboard", ArtistFavoriteAdd = "ArtistFavoriteAdd", ArtistFavoriteRemove = "ArtistFavoriteRemove", ArtistFavoriteUpdate = "ArtistFavoriteUpdate", + ArtistSearchPlaylists = "ArtistSearchPlaylists", + ArtistSearchRadio = "ArtistSearchRadio", + ArtistSearchTracks = "ArtistSearchTracks", } @@ -137,6 +152,50 @@ class AlbumActions extends FavActionsBase { `; + // define dropdown menu actions - album. + const actionsAlbumHtml = html` + + + + + this.onClickAction(Actions.AlbumSearchRadio)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}> + +
Search for Album Radio
+
+ + this.onClickAction(Actions.AlbumCopyUriToClipboard)}> + +
Copy Album URI to Clipboard
+
+
+ `; + + // define dropdown menu actions - artist. + const actionsArtistHtml = html` + + + + + this.onClickAction(Actions.ArtistSearchPlaylists)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}> + +
Search Playlists for Artist
+
+ this.onClickAction(Actions.ArtistSearchTracks)} hide=${this.hideSearchType(SearchMediaTypes.TRACKS)}> + +
Search Tracks for Artist
+
+ this.onClickAction(Actions.ArtistSearchRadio)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}> + +
Search for Artist Radio
+
+ + this.onClickAction(Actions.ArtistCopyUriToClipboard)}> + +
Copy Artist URI to Clipboard
+
+
+ `; + // render html. // mediaItem will be an IAlbum object when displaying favorites. // mediaItem will be an IAlbumSimplified object when displaying search results, @@ -151,11 +210,17 @@ class AlbumActions extends FavActionsBase { ${iconAlbum} ${this.mediaItem.name} ${(this.isAlbumFavorite ? actionAlbumFavoriteRemove : actionAlbumFavoriteAdd)} + + ${actionsAlbumHtml} +
${iconArtist} ${this.mediaItem.artists[0].name} ${(this.isArtistFavorite ? actionArtistFavoriteRemove : actionArtistFavoriteAdd)} + + ${actionsArtistHtml} +
Released
@@ -185,9 +250,9 @@ class AlbumActions extends FavActionsBase {
Duration
${this.albumTracks?.items.map((item) => html` this.onClickMediaItem(item)} + .path=${mdiPlaylistPlay} + .label="Add track "${item.name}" to Play Queue" + @click=${() => this.AddPlayerQueueItem(item)} slot="icon-button" > 
${item.track_number}
@@ -254,41 +319,88 @@ class AlbumActions extends FavActionsBase { return true; } - // show progress indicator. - this.progressShow(); + try { + + // process actions that don't require a progress indicator. + if (action == Actions.AlbumCopyUriToClipboard) { + + copyTextToClipboard(this.mediaItem.uri); + return true; + + } else if (action == Actions.AlbumSearchRadio) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.mediaItem.name + RADIO_SEARCH_KEY + this.mediaItem.artists[0].name)); + return true; + + } else if (action == Actions.ArtistCopyUriToClipboard) { + + copyTextToClipboard(this.mediaItem.artists[0].uri); + return true; + + } else if (action == Actions.ArtistSearchPlaylists) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.mediaItem.artists[0].name)); + return true; + + } else if (action == Actions.ArtistSearchRadio) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.mediaItem.artists[0].name + RADIO_SEARCH_KEY)); + return true; + + } else if (action == Actions.ArtistSearchTracks) { - // get Spotify Id values for currently selected content. - const uriIdArtist = getIdFromSpotifyUri(this.mediaItem.artists[0].uri); + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.TRACKS, this.mediaItem.artists[0].name)); + return true; - // call service based on requested action, and refresh affected action component. - if (action == Actions.AlbumFavoriteAdd) { + } + + // show progress indicator. + this.progressShow(); + + // get Spotify Id values for currently selected content. + const uriIdArtist = getIdFromSpotifyUri(this.mediaItem.artists[0].uri); + + // call service based on requested action, and refresh affected action component. + if (action == Actions.AlbumFavoriteAdd) { - await this.spotifyPlusService.SaveAlbumFavorites(this.player.id, this.mediaItem.id); - this.updateActions(this.player, [Actions.AlbumFavoriteUpdate]); + await this.spotifyPlusService.SaveAlbumFavorites(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.AlbumFavoriteUpdate]); - } else if (action == Actions.AlbumFavoriteRemove) { + } else if (action == Actions.AlbumFavoriteRemove) { - await this.spotifyPlusService.RemoveAlbumFavorites(this.player.id, this.mediaItem.id); - this.updateActions(this.player, [Actions.AlbumFavoriteUpdate]); + await this.spotifyPlusService.RemoveAlbumFavorites(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.AlbumFavoriteUpdate]); - } else if (action == Actions.ArtistFavoriteAdd) { + } else if (action == Actions.ArtistFavoriteAdd) { - await this.spotifyPlusService.FollowArtists(this.player.id, uriIdArtist); - this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); + await this.spotifyPlusService.FollowArtists(this.player.id, uriIdArtist); + this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); - } else if (action == Actions.ArtistFavoriteRemove) { + } else if (action == Actions.ArtistFavoriteRemove) { - await this.spotifyPlusService.UnfollowArtists(this.player.id, uriIdArtist); - this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); + await this.spotifyPlusService.UnfollowArtists(this.player.id, uriIdArtist); + this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); - } else { + } else { + + // no action selected - hide progress indicator. + this.progressHide(); + + } - // no action selected - hide progress indicator. + return true; + } + catch (error) { + + // clear the progress indicator and set alert error message. this.progressHide(); + this.alertErrorSet("Action failed: \n" + (error as Error).message); + return true; } + finally { + } - return true; } diff --git a/src/components/artist-actions.ts b/src/components/artist-actions.ts index 21f97ff..820836b 100644 --- a/src/components/artist-actions.ts +++ b/src/components/artist-actions.ts @@ -1,32 +1,44 @@ // lovelace card imports. import { css, html, TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; +import copyTextToClipboard from 'copy-text-to-clipboard'; import { mdiAccountMusic, + mdiClipboardPlusOutline, mdiHeart, mdiHeartOutline, + mdiDotsHorizontal, + mdiMusic, mdiPlay, + mdiPlaylistPlay, + mdiRadio, } from '@mdi/js'; // our imports. -import { sharedStylesGrid } from '../styles/shared-styles-grid.js'; -import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.js'; -import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; +import { sharedStylesGrid } from '../styles/shared-styles-grid'; +import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info'; +import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions'; import { FavActionsBase } from './fav-actions-base'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; -import { IAlbumPageSimplified } from '../types/spotifyplus/album-page-simplified.js'; -import { IArtist } from '../types/spotifyplus/artist'; -import { openWindowNewTab } from '../utils/media-browser-utils.js'; +import { IAlbumPageSimplified } from '../types/spotifyplus/album-page-simplified'; +import { IArtist, GetGenres } from '../types/spotifyplus/artist'; +import { openWindowNewTab } from '../utils/media-browser-utils'; +import { SearchMediaTypes } from '../types/search-media-types'; +import { SearchMediaEvent } from '../events/search-media'; /** * Artist actions. */ enum Actions { + ArtistAlbumsUpdate = "ArtistAlbumsUpdate", + ArtistCopyUriToClipboard = "ArtistCopyUriToClipboard", ArtistFavoriteAdd = "ArtistFavoriteAdd", ArtistFavoriteRemove = "ArtistFavoriteRemove", ArtistFavoriteUpdate = "ArtistFavoriteUpdate", - ArtistAlbumsUpdate = "ArtistAlbumsUpdate", + ArtistSearchPlaylists = "ArtistSearchPlaylists", + ArtistSearchRadio = "ArtistSearchRadio", + ArtistSearchTracks = "ArtistSearchTracks", } @@ -96,6 +108,32 @@ class ArtistActions extends FavActionsBase {
`; + // define dropdown menu actions - artist. + const actionsArtistHtml = html` + + + + + this.onClickAction(Actions.ArtistSearchPlaylists)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}> + +
Search Playlists for Artist
+
+ this.onClickAction(Actions.ArtistSearchTracks)} hide=${this.hideSearchType(SearchMediaTypes.TRACKS)}> + +
Search Tracks for Artist
+
+ this.onClickAction(Actions.ArtistSearchRadio)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}> + +
Search for Artist Radio
+
+ + this.onClickAction(Actions.ArtistCopyUriToClipboard)}> + +
Copy Artist URI to Clipboard
+
+
+ `; + // render html. return html`
@@ -107,6 +145,9 @@ class ArtistActions extends FavActionsBase { ${iconArtist} ${this.mediaItem.name} ${(this.isArtistFavorite ? actionArtistFavoriteRemove : actionArtistFavoriteAdd)} + + ${actionsArtistHtml} +
${this.mediaItem.followers ? html` @@ -116,7 +157,7 @@ class ArtistActions extends FavActionsBase {
${this.mediaItem.genres.length > 0 ? html`
Genres
-
${this.mediaItem.genres}
+
${GetGenres(this.mediaItem)}
` : html`
`}
@@ -157,7 +198,7 @@ class ArtistActions extends FavActionsBase { css` .artist-info-grid { - grid-template-columns: auto 10px auto; + grid-template-columns: auto auto auto; justify-content: left; } @@ -180,7 +221,6 @@ class ArtistActions extends FavActionsBase { vertical-align: top; padding: 0px; } - ` ]; } @@ -199,28 +239,65 @@ class ArtistActions extends FavActionsBase { return true; } - // show progress indicator. - this.progressShow(); + try { + + // process actions that don't require a progress indicator. + if (action == Actions.ArtistCopyUriToClipboard) { + + copyTextToClipboard(this.mediaItem.uri); + return true; + + } else if (action == Actions.ArtistSearchPlaylists) { - // call service based on requested action, and refresh affected action component. - if (action == Actions.ArtistFavoriteAdd) { + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.mediaItem.name)); + return true; - await this.spotifyPlusService.FollowArtists(this.player.id, this.mediaItem.id); - this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); + } else if (action == Actions.ArtistSearchRadio) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.mediaItem.name + " Radio")); + return true; + + } else if (action == Actions.ArtistSearchTracks) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.TRACKS, this.mediaItem.name)); + return true; + + } + + // show progress indicator. + this.progressShow(); - } else if (action == Actions.ArtistFavoriteRemove) { + // call service based on requested action, and refresh affected action component. + if (action == Actions.ArtistFavoriteAdd) { - await this.spotifyPlusService.UnfollowArtists(this.player.id, this.mediaItem.id); - this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); + await this.spotifyPlusService.FollowArtists(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); - } else { + } else if (action == Actions.ArtistFavoriteRemove) { - // no action selected - hide progress indicator. + await this.spotifyPlusService.UnfollowArtists(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); + + } else { + + // no action selected - hide progress indicator. + this.progressHide(); + + } + + return true; + } + catch (error) { + + // clear the progress indicator and set alert error message. this.progressHide(); + this.alertErrorSet("Action failed: \n" + (error as Error).message); + return true; } + finally { + } - return true; } diff --git a/src/components/audiobook-actions.ts b/src/components/audiobook-actions.ts index 034b163..30c5037 100644 --- a/src/components/audiobook-actions.ts +++ b/src/components/audiobook-actions.ts @@ -1,11 +1,15 @@ // lovelace card imports. import { css, html, TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; +import copyTextToClipboard from 'copy-text-to-clipboard'; import { + mdiAccountDetailsOutline, mdiBookOpenVariant, + mdiClipboardPlusOutline, + mdiDotsHorizontal, mdiHeart, mdiHeartOutline, - mdiPlay, + mdiPlaylistPlay, } from '@mdi/js'; // our imports. @@ -15,21 +19,26 @@ import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; import { FavActionsBase } from './fav-actions-base'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; -import { GetResumeInfo } from '../types/spotifyplus/resume-point'; -import { IAudiobookSimplified, GetAudiobookNarrators, GetAudiobookAuthors } from '../types/spotifyplus/audiobook-simplified'; -import { IChapterPageSimplified } from '../types/spotifyplus/chapter-page-simplified'; +import { SearchMediaTypes } from '../types/search-media-types'; +import { SearchMediaEvent } from '../events/search-media'; import { GetCopyrights } from '../types/spotifyplus/copyright'; +import { GetResumeInfo } from '../types/spotifyplus/resume-point'; import { formatDateHHMMSSFromMilliseconds, unescapeHtml } from '../utils/utils'; import { openWindowNewTab } from '../utils/media-browser-utils'; +import { IAudiobookSimplified, GetAudiobookNarrators, GetAudiobookAuthors } from '../types/spotifyplus/audiobook-simplified'; +import { IChapterPageSimplified } from '../types/spotifyplus/chapter-page-simplified'; /** * Audiobook actions. */ enum Actions { + AudiobookCopyUriToClipboard = "AudiobookCopyUriToClipboard", AudiobookFavoriteAdd = "AudiobookFavoriteAdd", AudiobookFavoriteRemove = "AudiobookFavoriteRemove", AudiobookFavoriteUpdate = "AudiobookFavoriteUpdate", AudiobookChaptersUpdate = "AudiobookChaptersUpdate", + AudiobookSearchAuthor = "AudiobookSearchAuthor", + AudiobookSearchNarrator = "AudiobookSearchNarrator", } @@ -99,6 +108,28 @@ class AudiobookActions extends FavActionsBase { `; + // define dropdown menu actions - audiobook. + const actionsAudiobookHtml = html` + + + + + this.onClickAction(Actions.AudiobookSearchAuthor)} hide=${this.hideSearchType(SearchMediaTypes.AUDIOBOOKS)}> + +
Other Audiobooks by same Author
+
+ this.onClickAction(Actions.AudiobookSearchNarrator)} hide=${this.hideSearchType(SearchMediaTypes.AUDIOBOOKS)}> + +
Other Audiobooks by same Narrator
+
+ + this.onClickAction(Actions.AudiobookCopyUriToClipboard)}> + +
Copy Audiobook URI to Clipboard
+
+
+ `; + // render html. // mediaItem will be an IAudiobook object when displaying favorites. // mediaItem will be an IAudiobookSimplified object when displaying search results, @@ -113,6 +144,9 @@ class AudiobookActions extends FavActionsBase { ${iconAudiobook} ${this.mediaItem.name} ${(this.isAudiobookFavorite ? actionAudiobookFavoriteRemove : actionAudiobookFavoriteAdd)} + + ${actionsAudiobookHtml} +
@@ -148,9 +182,9 @@ class AudiobookActions extends FavActionsBase {
Duration
${this.audiobookChapters?.items.map((item) => html` this.onClickMediaItem(item)} + .path=${mdiPlaylistPlay} + .label="Add chapter "${item.name}" to Play Queue" + @click=${() => this.AddPlayerQueueItem(item)} slot="icon-button" > 
${item.name}
@@ -217,28 +251,60 @@ class AudiobookActions extends FavActionsBase { return true; } - // show progress indicator. - this.progressShow(); + try { + + // process actions that don't require a progress indicator. + if (action == Actions.AudiobookCopyUriToClipboard) { + + copyTextToClipboard(this.mediaItem.uri || ""); + return true; + + } else if (action == Actions.AudiobookSearchAuthor) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.AUDIOBOOKS, GetAudiobookAuthors(this.mediaItem, " "))); + return true; + + } else if (action == Actions.AudiobookSearchNarrator) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.AUDIOBOOKS, GetAudiobookNarrators(this.mediaItem, " "))); + return true; + + } + + // show progress indicator. + this.progressShow(); + + // call service based on requested action, and refresh affected action component. + if (action == Actions.AudiobookFavoriteAdd) { - // call service based on requested action, and refresh affected action component. - if (action == Actions.AudiobookFavoriteAdd) { + await this.spotifyPlusService.SaveAudiobookFavorites(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.AudiobookFavoriteUpdate]); - await this.spotifyPlusService.SaveAudiobookFavorites(this.player.id, this.mediaItem.id); - this.updateActions(this.player, [Actions.AudiobookFavoriteUpdate]); + } else if (action == Actions.AudiobookFavoriteRemove) { - } else if (action == Actions.AudiobookFavoriteRemove) { + await this.spotifyPlusService.RemoveAudiobookFavorites(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.AudiobookFavoriteUpdate]); - await this.spotifyPlusService.RemoveAudiobookFavorites(this.player.id, this.mediaItem.id); - this.updateActions(this.player, [Actions.AudiobookFavoriteUpdate]); + } else { - } else { + // no action selected - hide progress indicator. + this.progressHide(); + + } - // no action selected - hide progress indicator. + return true; + } + catch (error) { + + // clear the progress indicator and set alert error message. this.progressHide(); + this.alertErrorSet("Action failed: \n" + (error as Error).message); + return true; } + finally { + } - return true; } diff --git a/src/components/device-actions.ts b/src/components/device-actions.ts index 818fdc6..f6b5f05 100644 --- a/src/components/device-actions.ts +++ b/src/components/device-actions.ts @@ -7,6 +7,7 @@ import { sharedStylesGrid } from '../styles/shared-styles-grid.js'; import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.js'; import { Store } from '../model/store'; import { ISpotifyConnectDevice } from '../types/spotifyplus/spotify-connect-device'; +import { copyToClipboard } from '../utils/utils.js'; class DeviceActions extends LitElement { @@ -19,10 +20,10 @@ class DeviceActions extends LitElement { @state() private _alertError?: string; - /** - * Invoked on each update to perform rendering tasks. - * This method may return any value renderable by lit-html's `ChildPart` (typically a `TemplateResult`). - * Setting properties inside this method will *not* trigger the element to update. + /** + * Invoked on each update to perform rendering tasks. + * This method may return any value renderable by lit-html's `ChildPart` (typically a `TemplateResult`). + * Setting properties inside this method will *not* trigger the element to update. */ protected render(): TemplateResult | void { @@ -33,35 +34,40 @@ class DeviceActions extends LitElement {
-
${this.mediaItem.Name}
-
${this.mediaItem.DeviceInfo.BrandDisplayName}
+
${this.mediaItem.Name}
+
${this.mediaItem.DeviceInfo.BrandDisplayName}
${this.mediaItem.DeviceInfo.ModelDisplayName}
-
Product ID: ${this.mediaItem.DeviceInfo.ProductId}
Device ID
-
${this.mediaItem.DeviceInfo.DeviceId}
+
${this.mediaItem.DeviceInfo.DeviceId}
Device Name
-
${this.mediaItem.DiscoveryResult.DeviceName}
+
${this.mediaItem.DiscoveryResult.DeviceName}
Device Type
${this.mediaItem.DeviceInfo.DeviceType}
+
Product ID
+
${this.mediaItem.DeviceInfo.ProductId}
+ +
Voice Support?
+
${this.mediaItem.DeviceInfo.VoiceSupport}
+
IP DNS Alias
-
${this.mediaItem.DiscoveryResult.Server}
+
${this.mediaItem.DiscoveryResult.Server}
IP Address
-
${this.mediaItem.DiscoveryResult.HostIpAddress}
+
${this.mediaItem.DiscoveryResult.HostIpAddress}
Zeroconf IP Port
-
${this.mediaItem.DiscoveryResult.HostIpPort}
+
${this.mediaItem.DiscoveryResult.HostIpPort}
Zeroconf CPath
-
${this.mediaItem.DiscoveryResult.SpotifyConnectCPath}
+
${this.mediaItem.DiscoveryResult.SpotifyConnectCPath}
Is Dynamic Device?
${this.mediaItem.DiscoveryResult.IsDynamicDevice}
@@ -70,19 +76,13 @@ class DeviceActions extends LitElement {
${this.mediaItem.DeviceInfo.IsInDeviceList}
Auth Token Type
-
${this.mediaItem.DeviceInfo.TokenType}
+
${this.mediaItem.DeviceInfo.TokenType}
Client ID
-
${this.mediaItem.DeviceInfo.ClientId}
+
${this.mediaItem.DeviceInfo.ClientId}
Library Version
-
${this.mediaItem.DeviceInfo.LibraryVersion}
- -
Group Status
-
${this.mediaItem.DeviceInfo.GroupStatus}
- -
Voice Support?
-
${this.mediaItem.DeviceInfo.VoiceSupport}
+
${this.mediaItem.DeviceInfo.LibraryVersion}
@@ -111,6 +111,10 @@ class DeviceActions extends LitElement { justify-content: left; } + .copy2cb:hover { + cursor: copy; + } + /* style ha-alert controls */ ha-alert { display: block; diff --git a/src/components/episode-actions.ts b/src/components/episode-actions.ts index 30e5c9c..df1ce78 100644 --- a/src/components/episode-actions.ts +++ b/src/components/episode-actions.ts @@ -1,11 +1,14 @@ // lovelace card imports. import { css, html, TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; +import copyTextToClipboard from 'copy-text-to-clipboard'; import { - mdiPodcast, + mdiClipboardPlusOutline, + mdiDotsHorizontal, mdiHeart, mdiHeartOutline, mdiMicrophone, + mdiPodcast, } from '@mdi/js'; // our imports. @@ -15,6 +18,8 @@ import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; import { FavActionsBase } from './fav-actions-base'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; +import { SearchMediaTypes } from '../types/search-media-types'; +import { SearchMediaEvent } from '../events/search-media'; import { formatDateHHMMSSFromMilliseconds, unescapeHtml } from '../utils/utils'; import { openWindowNewTab } from '../utils/media-browser-utils'; import { IEpisode, isEpisodeObject } from '../types/spotifyplus/episode'; @@ -24,13 +29,16 @@ import { IEpisodeSimplified } from '../types/spotifyplus/episode-simplified'; * Episode actions. */ enum Actions { + EpisodeCopyUriToClipboard = "EpisodeCopyUriToClipboard", EpisodeFavoriteAdd = "EpisodeFavoriteAdd", EpisodeFavoriteRemove = "EpisodeFavoriteRemove", EpisodeFavoriteUpdate = "EpisodeFavoriteUpdate", EpisodeUpdate = "EpisodeUpdate", + ShowCopyUriToClipboard = "ShowCopyUriToClipboard", ShowFavoriteAdd = "ShowFavoriteAdd", ShowFavoriteRemove = "ShowFavoriteRemove", ShowFavoriteUpdate = "ShowFavoriteUpdate", + ShowSearchEpisodes = "ShowSearchEpisodes", } @@ -134,6 +142,37 @@ class EpisodeActions extends FavActionsBase {
`; + // define dropdown menu actions - show. + const actionsShowHtml = html` + + + + + this.onClickAction(Actions.ShowSearchEpisodes)} hide=${this.hideSearchType(SearchMediaTypes.EPISODES)}> + +
Search for Show Episodes
+
+ + this.onClickAction(Actions.ShowCopyUriToClipboard)}> + +
Copy Show URI to Clipboard
+
+
+ `; + + // define dropdown menu actions - episode. + const actionsEpisodeHtml = html` + + + + + this.onClickAction(Actions.EpisodeCopyUriToClipboard)}> + +
Copy Episode URI to Clipboard
+
+
+ `; + // render html. // note that mediaItem could be an IEpisode or IEpisodeSimplified object. return html` @@ -146,11 +185,17 @@ class EpisodeActions extends FavActionsBase { ${iconEpisode} ${this.episode?.name} ${(this.isEpisodeFavorite ? actionEpisodeFavoriteRemove : actionEpisodeFavoriteAdd)} + + ${actionsEpisodeHtml} +
${iconShow} ${this.episode?.show.name} ${(this.isShowFavorite ? actionShowFavoriteRemove : actionShowFavoriteAdd)} + + ${actionsShowHtml} +
Duration
@@ -228,38 +273,70 @@ class EpisodeActions extends FavActionsBase { return true; } - // show progress indicator. - this.progressShow(); + try { + + // process actions that don't require a progress indicator. + if (action == Actions.EpisodeCopyUriToClipboard) { - // call service based on requested action, and refresh affected action component. - if (action == Actions.ShowFavoriteAdd) { + copyTextToClipboard(this.episode?.uri || ""); + return true; - await this.spotifyPlusService.SaveShowFavorites(this.player.id, this.episode?.show.id); - this.updateActions(this.player, [Actions.ShowFavoriteUpdate]); + } else if (action == Actions.ShowCopyUriToClipboard) { + + copyTextToClipboard(this.episode?.show.uri || ""); + return true; + + } else if (action == Actions.ShowSearchEpisodes) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.EPISODES, this.episode?.show.name)); + return true; + + } + + // show progress indicator. + this.progressShow(); - } else if (action == Actions.ShowFavoriteRemove) { + // call service based on requested action, and refresh affected action component. + if (action == Actions.EpisodeFavoriteAdd) { - await this.spotifyPlusService.RemoveShowFavorites(this.player.id, this.episode?.show.id); - this.updateActions(this.player, [Actions.ShowFavoriteUpdate]); + await this.spotifyPlusService.SaveEpisodeFavorites(this.player.id, this.episode?.id); + this.updateActions(this.player, [Actions.EpisodeFavoriteUpdate]); - } else if (action == Actions.EpisodeFavoriteAdd) { + } else if (action == Actions.EpisodeFavoriteRemove) { - await this.spotifyPlusService.SaveEpisodeFavorites(this.player.id, this.episode?.id); - this.updateActions(this.player, [Actions.EpisodeFavoriteUpdate]); + await this.spotifyPlusService.RemoveEpisodeFavorites(this.player.id, this.episode?.id); + this.updateActions(this.player, [Actions.EpisodeFavoriteUpdate]); - } else if (action == Actions.EpisodeFavoriteRemove) { + } else if (action == Actions.ShowFavoriteAdd) { - await this.spotifyPlusService.RemoveEpisodeFavorites(this.player.id, this.episode?.id); - this.updateActions(this.player, [Actions.EpisodeFavoriteUpdate]); + await this.spotifyPlusService.SaveShowFavorites(this.player.id, this.episode?.show.id); + this.updateActions(this.player, [Actions.ShowFavoriteUpdate]); - } else { + } else if (action == Actions.ShowFavoriteRemove) { - // no action selected - hide progress indicator. + await this.spotifyPlusService.RemoveShowFavorites(this.player.id, this.episode?.show.id); + this.updateActions(this.player, [Actions.ShowFavoriteUpdate]); + + } else { + + // no action selected - hide progress indicator. + this.progressHide(); + + } + + return true; + } + catch (error) { + + // clear the progress indicator and set alert error message. this.progressHide(); + this.alertErrorSet("Action failed: \n" + (error as Error).message); + return true; } + finally { + } - return true; } diff --git a/src/components/fav-actions-base.ts b/src/components/fav-actions-base.ts index 38bd3fc..30d2e84 100644 --- a/src/components/fav-actions-base.ts +++ b/src/components/fav-actions-base.ts @@ -10,6 +10,7 @@ import { SpotifyPlusService } from '../services/spotifyplus-service'; import { isCardInEditPreview } from '../utils/utils'; import { ProgressStartedEvent } from '../events/progress-started'; import { ProgressEndedEvent } from '../events/progress-ended'; +import { SearchMediaTypes } from '../types/search-media-types'; // debug logging. import Debug from 'debug/src/browser.js'; @@ -163,6 +164,27 @@ export class FavActionsBase extends LitElement { } + /** + * Returns false if the specified feature is to be SHOWN; otherwise, returns true + * if the specified feature is to be HIDDEN (via CSS). + * + * @param searchType Search type to check. + */ + protected hideSearchType(searchType: SearchMediaTypes) { + + if ((this.store.config.searchMediaBrowserSearchTypes) && (this.store.config.searchMediaBrowserSearchTypes.length > 0)) { + if (this.store.config.searchMediaBrowserSearchTypes?.includes(searchType)) { + return false; // show searchType + } else { + return true; // hide searchType. + } + } + + // if features not configured, then show search type. + return false; + } + + /** * Handles the `click` event fired when a media item control icon is clicked. * @@ -176,6 +198,39 @@ export class FavActionsBase extends LitElement { } + /** + * Calls the SpotifyPlusService AddPlayerQueueItems method to add track / episode + * to play queue. + * + * @param mediaItem The medialist item that was selected. + */ + protected async AddPlayerQueueItem(mediaItem: any) { + + try { + + // show progress indicator. + this.progressShow(); + + // add media item to play queue. + await this.spotifyPlusService.AddPlayerQueueItems(this.player.id, mediaItem.uri, null, false); + + } + catch (error) { + + // set error status, + this.alertErrorSet("Could not add media item to play queue. " + (error as Error).message); + + } + finally { + + // hide progress indicator. + this.progressHide(); + + } + + } + + /** * Calls the SpotifyPlusService Card_PlayMediaBrowserItem method to play media. * diff --git a/src/components/footer.ts b/src/components/footer.ts index 5b94581..15d8cec 100644 --- a/src/components/footer.ts +++ b/src/components/footer.ts @@ -137,11 +137,6 @@ export class Footer extends LitElement { :host > *[hide] { display: none; } - - .ha-icon-button { - --mwc-icon-button-size: 3rem; - --mwc-icon-size: 2rem; - } `; } diff --git a/src/components/player-body-audiobook.ts b/src/components/player-body-audiobook.ts index 5733930..ad68d17 100644 --- a/src/components/player-body-audiobook.ts +++ b/src/components/player-body-audiobook.ts @@ -1,8 +1,12 @@ // lovelace card imports. import { css, html, TemplateResult } from 'lit'; import { state } from 'lit/decorators.js'; +import copyTextToClipboard from 'copy-text-to-clipboard'; import { + mdiAccountDetailsOutline, mdiBookOpenVariant, + mdiClipboardPlusOutline, + mdiDotsHorizontal, mdiHeart, mdiHeartOutline, mdiMicrophone, @@ -14,23 +18,28 @@ import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.js'; import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; import { PlayerBodyBase } from './player-body-base'; import { MediaPlayer } from '../model/media-player'; +import { SearchMediaTypes } from '../types/search-media-types'; +import { SearchMediaEvent } from '../events/search-media'; import { getIdFromSpotifyUri } from '../services/spotifyplus-service'; +import { formatDateHHMMSSFromMilliseconds, unescapeHtml } from '../utils/utils'; +import { openWindowNewTab } from '../utils/media-browser-utils'; +import { GetAudiobookAuthors, GetAudiobookNarrators } from '../types/spotifyplus/audiobook-simplified'; import { IChapter } from '../types/spotifyplus/chapter'; -import { formatDateHHMMSSFromMilliseconds, unescapeHtml } from '../utils/utils.js'; -import { openWindowNewTab } from '../utils/media-browser-utils.js'; -import { GetAudiobookAuthors, GetAudiobookNarrators } from '../types/spotifyplus/audiobook-simplified.js'; /** * Audiobook actions. */ enum Actions { - GetPlayingItem = "GetPlayingItem", + AudiobookCopyUriToClipboard = "AudiobookCopyUriToClipboard", AudiobookFavoriteAdd = "AudiobookFavoriteAdd", AudiobookFavoriteRemove = "AudiobookFavoriteRemove", AudiobookFavoriteUpdate = "AudiobookFavoriteUpdate", ChapterFavoriteAdd = "ChapterFavoriteAdd", ChapterFavoriteRemove = "ChapterFavoriteRemove", ChapterFavoriteUpdate = "ChapterFavoriteUpdate", + GetPlayingItem = "GetPlayingItem", + AudiobookSearchAuthor = "AudiobookSearchAuthor", + AudiobookSearchNarrator = "AudiobookSearchNarrator", } @@ -120,6 +129,28 @@ class PlayerBodyAudiobook extends PlayerBodyBase {
`; + // define dropdown menu actions - audiobook. + const actionsAudiobookHtml = html` + + + + + this.onClickAction(Actions.AudiobookSearchAuthor)} hide=${this.hideSearchType(SearchMediaTypes.AUDIOBOOKS)}> + +
Other Audiobooks by same Author
+
+ this.onClickAction(Actions.AudiobookSearchNarrator)} hide=${this.hideSearchType(SearchMediaTypes.AUDIOBOOKS)}> + +
Other Audiobooks by same Narrator
+
+ + this.onClickAction(Actions.AudiobookCopyUriToClipboard)}> + +
Copy Audiobook URI to Clipboard
+
+
+ `; + const actionEpisodeSummary = html`
@@ -127,6 +158,9 @@ class PlayerBodyAudiobook extends PlayerBodyBase { ${iconAudiobook} ${this.chapter?.audiobook.name} ${(this.isAudiobookFavorite ? actionAudiobookFavoriteRemove : actionAudiobookFavoriteAdd)} + + ${actionsAudiobookHtml} +
${iconChapter} @@ -245,6 +279,24 @@ class PlayerBodyAudiobook extends PlayerBodyBase { try { + // process actions that don't require a progress indicator. + if (action == Actions.AudiobookCopyUriToClipboard) { + + copyTextToClipboard(this.chapter?.audiobook.uri || ""); + return true; + + } else if (action == Actions.AudiobookSearchAuthor) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.AUDIOBOOKS, GetAudiobookAuthors(this.chapter?.audiobook, " "))); + return true; + + } else if (action == Actions.AudiobookSearchNarrator) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.AUDIOBOOKS, GetAudiobookNarrators(this.chapter?.audiobook, " "))); + return true; + + } + // show progress indicator. this.progressShow(); @@ -282,7 +334,7 @@ class PlayerBodyAudiobook extends PlayerBodyBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Audiobook action failed: \n" + (error as Error).message); + this.alertErrorSet("Action failed: \n" + (error as Error).message); return true; } diff --git a/src/components/player-body-base.ts b/src/components/player-body-base.ts index f4b5b2a..4f2e8d3 100644 --- a/src/components/player-body-base.ts +++ b/src/components/player-body-base.ts @@ -5,13 +5,14 @@ import { property, state } from 'lit/decorators.js'; // our imports. import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; import { Store } from '../model/store'; -import { Section } from '../types/section.js'; +import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; import { MediaPlayerState } from '../services/media-control-service'; import { SpotifyPlusService } from '../services/spotifyplus-service'; -import { isCardInEditPreview } from '../utils/utils'; -import { ProgressEndedEvent } from '../events/progress-ended.js'; -import { ProgressStartedEvent } from '../events/progress-started.js'; +import { SearchMediaTypes } from '../types/search-media-types'; +import { isCardInEditPreview, loadHaFormLazyControls } from '../utils/utils'; +import { ProgressEndedEvent } from '../events/progress-ended'; +import { ProgressStartedEvent } from '../events/progress-started'; // debug logging. import Debug from 'debug/src/browser.js'; @@ -135,6 +136,10 @@ export class PlayerBodyBase extends LitElement { // invoke base class method. super.firstUpdated(changedProperties); + // ensure "" and "" HA customElements are + // loaded so that the controls are rendered properly. + (async () => await loadHaFormLazyControls())(); + // if we are editing the card configuration, then don't bother updating actions as // the user cannot display the actions dialog while editing the card configuration. if (this.isCardInEditPreview) { @@ -240,6 +245,27 @@ export class PlayerBodyBase extends LitElement { } + /** + * Returns false if the specified feature is to be SHOWN; otherwise, returns true + * if the specified feature is to be HIDDEN (via CSS). + * + * @param searchType Search type to check. + */ + protected hideSearchType(searchType: SearchMediaTypes) { + + if ((this.store.config.searchMediaBrowserSearchTypes) && (this.store.config.searchMediaBrowserSearchTypes.length > 0)) { + if (this.store.config.searchMediaBrowserSearchTypes?.includes(searchType)) { + return false; // show searchType + } else { + return true; // hide searchType. + } + } + + // if features not configured, then show search type. + return false; + } + + /** * Handles the `click` event fired when a control icon is clicked. * This method should be overridden by the inheriting class. @@ -281,6 +307,9 @@ export class PlayerBodyBase extends LitElement { if (!this.isUpdateInProgress) { this.isUpdateInProgress = true; } else { + if (debuglog.enabled) { + debuglog("updateActions - update in progress; ignoring updateActions request"); + } return false; } @@ -288,18 +317,27 @@ export class PlayerBodyBase extends LitElement { // display the actions dialog. if (this.isCardInEditPreview) { this.isUpdateInProgress = false; + if (debuglog.enabled) { + debuglog("updateActions - card is in editpreview; ignoring updateActions request"); + } return false; } // if player reference not set then we are done. if (!player) { this.isUpdateInProgress = false; + if (debuglog.enabled) { + debuglog("updateActions - player reference not set; ignoring updateActions request"); + } return false; } // if no media content id, then don't bother. if (!this.player.attributes.media_content_id) { this.isUpdateInProgress = false; + if (debuglog.enabled) { + debuglog("updateActions - player media_content_id reference not set; ignoring updateActions request"); + } return false; } diff --git a/src/components/player-body-queue.ts b/src/components/player-body-queue.ts index e1557fa..733591f 100644 --- a/src/components/player-body-queue.ts +++ b/src/components/player-body-queue.ts @@ -13,15 +13,19 @@ import { PlayerBodyBase } from './player-body-base'; import { MediaPlayer } from '../model/media-player'; import { IPlayerQueueInfo } from '../types/spotifyplus/player-queue-info.js'; +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from '../constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":player-body-queue"); + /** * Track actions. */ enum Actions { - GetPlayerQueueInfo = "GetPlayerQueueInfo", + ChapterPlay = "ChapterPlay", EpisodePlay = "EpisodePlay", - EpisodeRemove = "EpisodeRemove", + GetPlayerQueueInfo = "GetPlayerQueueInfo", TrackPlay = "TrackPlay", - TrackRemove = "TrackRemove", } @@ -41,62 +45,68 @@ export class PlayerBodyQueue extends PlayerBodyBase { // invoke base class method. super.render(); - // generate queue items info based on content type. - let queueItems = html``; - if (this.player.attributes.sp_item_type == 'track') { + // initialize common elements. + let queueInfoTitle = html`Unknown`; + let queueInfoParentTitle = html`Title Type`; + let queueItems = html`
No items found in Queue
`; - queueItems = html` -
- Player Queue Info - Tracks -
-
-
-
 
-
#
-
Title
-
Artist
- ${this.queueInfo?.queue.map((item, index) => html` - this.onClickAction(Actions.TrackPlay, item)} - slot="icon-button" - >  -
${index + 1}
-
${item.name || ""}
-
${item.artists[0].name || ""}
- `)} -
-
- `; - - } else { + // generate queue items info based on content type. + if (this.player.attributes.sp_item_type == 'podcast') { + + // build queue info display for podcast episodes. + queueInfoTitle = html`Episodes`; + queueInfoParentTitle = html`Show`; + if ((this.queueInfo?.queue || []).length > 0) { + queueItems = html`${this.queueInfo?.queue.map((item, index) => html` + this.onClickAction(Actions.TrackPlay, item)} + slot="icon-button" + >  +
${index + 1}
+
${item.name || ""}
+
${item.artists[0].name || ""}
+ `)}`; + } - queueItems = html` -
- Player Queue Info - Episodes -
-
-
-
 
-
#
-
Title
-
Show
- ${this.queueInfo?.queue.map((item, index) => html` - this.onClickAction(Actions.EpisodePlay, item)} - slot="icon-button" - >  -
${index + 1}
-
${item.name || ""}
-
${item.show?.name || ""}
- `)} -
-
- `; + } else if (this.player.attributes.sp_item_type == 'audiobook') { + + // build queue info display for audiobook chapters. + queueInfoTitle = html`Chapters`; + queueInfoParentTitle = html`Audiobook`; + if ((this.queueInfo?.queue || []).length > 0) { + queueItems = html`${this.queueInfo?.queue.map((item, index) => html` + this.onClickAction(Actions.ChapterPlay, item)} + slot="icon-button" + >  +
${index + 1}
+
${item.name || ""}
+
${item.show?.name || ""}
+ `)}`; + } + } else if (this.player.attributes.sp_item_type == 'track') { + + // build queue info display for tracks. + queueInfoTitle = html`Tracks`; + queueInfoParentTitle = html`Artist`; + if ((this.queueInfo?.queue || []).length > 0) { + queueItems = html`${this.queueInfo?.queue.map((item, index) => html` + this.onClickAction(Actions.TrackPlay, item)} + slot="icon-button" + >  +
${index + 1}
+
${item.name || ""}
+
${item.artists[0].name || ""}
+ `)}`; + } } // render html. @@ -104,7 +114,18 @@ export class PlayerBodyQueue extends PlayerBodyBase {
${this.alertError ? html`${this.alertError}` : ""} - ${queueItems} +
+ Player Queue Info - ${queueInfoTitle} +
+
+
+
 
+
#
+
Title
+
${queueInfoParentTitle}
+ ${queueItems} +
+
`; } @@ -120,18 +141,24 @@ export class PlayerBodyQueue extends PlayerBodyBase { sharedStylesFavActions, css` - /* style tracks container */ - .tracks-grid-container { + /* style grid container */ + .queue-info-grid-container { margin: 0.25rem; } - /* style tracks container and grid */ - .tracks-grid { + /* style grid container and grid items */ + .queue-info-grid { grid-template-columns: 30px 45px auto auto; } - /* style ha-icon-button controls in tracks grid: icon size, title text */ - .tracks-grid > ha-icon-button[slot="icon-button"] { + /* style grid container and grid items */ + .queue-info-grid-no-items { + grid-column-start: 1; + grid-column-end: 4; + } + + /* style ha-icon-button controls in grid: icon size, title text */ + .queue-info-grid > ha-icon-button[slot="icon-button"] { --mdc-icon-button-size: 24px; --mdc-icon-size: 20px; vertical-align: top; @@ -174,21 +201,20 @@ export class PlayerBodyQueue extends PlayerBodyBase { this.updateActions(this.player, [Actions.GetPlayerQueueInfo]); - } else if (action == Actions.TrackPlay) { + } else if (action == Actions.ChapterPlay) { await this.spotifyPlusService.Card_PlayMediaBrowserItem(this.player, item); - - } else if (action == Actions.TrackRemove) { - - //await this.spotifyPlusService.??(this.player, item); + this.progressHide(); } else if (action == Actions.EpisodePlay) { await this.spotifyPlusService.Card_PlayMediaBrowserItem(this.player, item); + this.progressHide(); - } else if (action == Actions.EpisodeRemove) { + } else if (action == Actions.TrackPlay) { - //await this.spotifyPlusService.??(this.player, item); + await this.spotifyPlusService.Card_PlayMediaBrowserItem(this.player, item); + this.progressHide(); } @@ -197,16 +223,13 @@ export class PlayerBodyQueue extends PlayerBodyBase { } catch (error) { - // set alert error message. - this.alertErrorSet("Track action failed: \n" + (error as Error).message); + // clear the progress indicator and set alert error message. + this.progressHide(); + this.alertErrorSet("Action failed: \n" + (error as Error).message); return true; } finally { - - // hide progress indicator. - this.progressHide(); - } } @@ -247,11 +270,24 @@ export class PlayerBodyQueue extends PlayerBodyBase { // load results, update favorites, and resolve the promise. this.queueInfo = result; - //this.requestUpdate(); - // update display. + + //// update the whole player body queue element. + //const spcPlayerBodyQueue = closestElement('#elmPlayerBodyQueue', this) as PlayerBodyQueue; + //spcPlayerBodyQueue.requestUpdate(); + + ////this.requestUpdate(); + //// update display. //setTimeout(() => { - // this.requestUpdate(); - //}, 50); + // //this.requestUpdate(); + // const spcPlayerBodyQueue = closestElement('#elmPlayerBodyQueue', this) as PlayerBodyQueue; + // spcPlayerBodyQueue.requestUpdate(); + // debuglog("updateActions - queueInfo refreshed successfully (setTimeout)"); + //}, 2000); + + if (debuglog.enabled) { + debuglog("updateActions - queueInfo refreshed successfully"); + } + resolve(true); }) diff --git a/src/components/player-body-show.ts b/src/components/player-body-show.ts index 343d32d..583b544 100644 --- a/src/components/player-body-show.ts +++ b/src/components/player-body-show.ts @@ -1,7 +1,10 @@ // lovelace card imports. import { css, html, TemplateResult } from 'lit'; import { state } from 'lit/decorators.js'; +import copyTextToClipboard from 'copy-text-to-clipboard'; import { + mdiClipboardPlusOutline, + mdiDotsHorizontal, mdiHeart, mdiHeartOutline, mdiMicrophone, @@ -14,22 +17,27 @@ import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.js'; import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; import { PlayerBodyBase } from './player-body-base'; import { MediaPlayer } from '../model/media-player'; -import { IEpisode } from '../types/spotifyplus/episode'; +import { SearchMediaTypes } from '../types/search-media-types'; +import { SearchMediaEvent } from '../events/search-media'; import { getIdFromSpotifyUri } from '../services/spotifyplus-service'; import { formatDateHHMMSSFromMilliseconds, unescapeHtml } from '../utils/utils'; -import { openWindowNewTab } from '../utils/media-browser-utils.js'; +import { openWindowNewTab } from '../utils/media-browser-utils'; +import { IEpisode } from '../types/spotifyplus/episode'; /** * Show actions. */ enum Actions { - GetPlayingItem = "GetPlayingItem", + EpisodeCopyUriToClipboard = "EpisodeCopyUriToClipboard", EpisodeFavoriteAdd = "EpisodeFavoriteAdd", EpisodeFavoriteRemove = "EpisodeFavoriteRemove", EpisodeFavoriteUpdate = "EpisodeFavoriteUpdate", + GetPlayingItem = "GetPlayingItem", + ShowCopyUriToClipboard = "ShowCopyUriToClipboard", ShowFavoriteAdd = "ShowFavoriteAdd", ShowFavoriteRemove = "ShowFavoriteRemove", ShowFavoriteUpdate = "ShowFavoriteUpdate", + ShowSearchEpisodes = "ShowSearchEpisodes", } @@ -119,6 +127,37 @@ class PlayerBodyShow extends PlayerBodyBase {
`; + // define dropdown menu actions - show. + const actionsShowHtml = html` + + + + + this.onClickAction(Actions.ShowSearchEpisodes)} hide=${this.hideSearchType(SearchMediaTypes.EPISODES)}> + +
Search for Show Episodes
+
+ + this.onClickAction(Actions.ShowCopyUriToClipboard)}> + +
Copy Show URI to Clipboard
+
+
+ `; + + // define dropdown menu actions - episode. + const actionsEpisodeHtml = html` + + + + + this.onClickAction(Actions.EpisodeCopyUriToClipboard)}> + +
Copy Episode URI to Clipboard
+
+
+ `; + const actionEpisodeSummary = html`
@@ -126,11 +165,17 @@ class PlayerBodyShow extends PlayerBodyBase { ${iconShow} ${this.episode?.show.name} ${(this.isShowFavorite ? actionShowFavoriteRemove : actionShowFavoriteAdd)} + + ${actionsShowHtml} +
${iconEpisode} ${this.episode?.name} ${(this.isEpisodeFavorite ? actionEpisodeFavoriteRemove : actionEpisodeFavoriteAdd)} + + ${actionsEpisodeHtml} +
Released On
@@ -217,6 +262,25 @@ class PlayerBodyShow extends PlayerBodyBase { //} try { + + // process actions that don't require a progress indicator. + if (action == Actions.EpisodeCopyUriToClipboard) { + + copyTextToClipboard(this.episode?.uri || ""); + return true; + + } else if (action == Actions.ShowCopyUriToClipboard) { + + copyTextToClipboard(this.episode?.show.uri || ""); + return true; + + } else if (action == Actions.ShowSearchEpisodes) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.EPISODES, this.episode?.show.name)); + return true; + + } + // show progress indicator. this.progressShow(); diff --git a/src/components/player-body-track.ts b/src/components/player-body-track.ts index 130b491..894bd1a 100644 --- a/src/components/player-body-track.ts +++ b/src/components/player-body-track.ts @@ -1,12 +1,17 @@ // lovelace card imports. import { css, html, TemplateResult } from 'lit'; import { state } from 'lit/decorators.js'; +import copyTextToClipboard from 'copy-text-to-clipboard'; import { mdiAccountMusic, mdiAlbum, + mdiClipboardPlusOutline, + mdiDotsHorizontal, mdiHeart, mdiHeartOutline, mdiMusic, + mdiPlaylistPlay, + mdiRadio, } from '@mdi/js'; // our imports. @@ -15,25 +20,37 @@ import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.js'; import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; import { PlayerBodyBase } from './player-body-base'; import { MediaPlayer } from '../model/media-player'; +import { SearchMediaTypes } from '../types/search-media-types'; +import { SearchMediaEvent } from '../events/search-media'; import { getIdFromSpotifyUri } from '../services/spotifyplus-service'; +import { openWindowNewTab } from '../utils/media-browser-utils'; +import { formatDateHHMMSSFromMilliseconds } from '../utils/utils'; +import { RADIO_SEARCH_KEY } from '../constants.js'; import { ITrack } from '../types/spotifyplus/track'; -import { openWindowNewTab } from '../utils/media-browser-utils.js'; -import { formatDateHHMMSSFromMilliseconds } from '../utils/utils.js'; /** * Track actions. */ enum Actions { GetPlayingItem = "GetPlayingItem", + AlbumCopyUriToClipboard = "AlbumCopyUriToClipboard", AlbumFavoriteAdd = "AlbumFavoriteAdd", AlbumFavoriteRemove = "AlbumFavoriteRemove", AlbumFavoriteUpdate = "AlbumFavoriteUpdate", + AlbumSearchRadio = "AlbumSearchRadio", + ArtistCopyUriToClipboard = "ArtistCopyUriToClipboard", ArtistFavoriteAdd = "ArtistFavoriteAdd", ArtistFavoriteRemove = "ArtistFavoriteRemove", ArtistFavoriteUpdate = "ArtistFavoriteUpdate", + ArtistSearchPlaylists = "ArtistSearchPlaylists", + ArtistSearchRadio = "ArtistSearchRadio", + ArtistSearchTracks = "ArtistSearchTracks", + TrackCopyUriToClipboard = "TrackCopyUriToClipboard", TrackFavoriteAdd = "TrackFavoriteAdd", TrackFavoriteRemove = "TrackFavoriteRemove", TrackFavoriteUpdate = "TrackFavoriteUpdate", + TrackSearchPlaylists = "TrackSearchPlaylists", + TrackSearchRadio = "TrackSearchRadio", } @@ -157,6 +174,72 @@ class PlayerBodyTrack extends PlayerBodyBase {
`; + // define dropdown menu actions - track. + const actionsTrackHtml = html` + + + + + this.onClickAction(Actions.TrackSearchPlaylists)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}> + +
Search Playlists for Track
+
+ this.onClickAction(Actions.TrackSearchRadio)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}> + +
Search for Track Radio
+
+ + this.onClickAction(Actions.TrackCopyUriToClipboard)}> + +
Copy Track URI to Clipboard
+
+
+ `; + + // define dropdown menu actions - album. + const actionsAlbumHtml = html` + + + + + this.onClickAction(Actions.AlbumSearchRadio)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}> + +
Search for Album Radio
+
+ + this.onClickAction(Actions.AlbumCopyUriToClipboard)}> + +
Copy Album URI to Clipboard
+
+
+ `; + + // define dropdown menu actions - artist. + const actionsArtistHtml = html` + + + + + this.onClickAction(Actions.ArtistSearchPlaylists)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}> + +
Search Playlists for Artist
+
+ this.onClickAction(Actions.ArtistSearchTracks)} hide=${this.hideSearchType(SearchMediaTypes.TRACKS)}> + +
Search Tracks for Artist
+
+ this.onClickAction(Actions.ArtistSearchRadio)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}> + +
Search for Artist Radio
+
+ + this.onClickAction(Actions.ArtistCopyUriToClipboard)}> + +
Copy Artist URI to Clipboard
+
+
+ `; + const actionTrackSummary = html`
@@ -164,16 +247,25 @@ class PlayerBodyTrack extends PlayerBodyBase { ${iconTrack} ${this.track?.name} ${(this.isTrackFavorite ? actionTrackFavoriteRemove : actionTrackFavoriteAdd)} + + ${actionsTrackHtml} +
${iconAlbum} ${this.track?.album.name} ${(this.isAlbumFavorite ? actionAlbumFavoriteRemove : actionAlbumFavoriteAdd)} + + ${actionsAlbumHtml} +
${iconArtist} ${this.track?.artists[0].name} ${(this.isArtistFavorite ? actionArtistFavoriteRemove : actionArtistFavoriteAdd)} + + ${actionsArtistHtml} +
Track #
@@ -268,6 +360,55 @@ class PlayerBodyTrack extends PlayerBodyBase { //} try { + + // process actions that don't require a progress indicator. + if (action == Actions.AlbumCopyUriToClipboard) { + + copyTextToClipboard(this.track?.album.uri || ""); + return true; + + } else if (action == Actions.AlbumSearchRadio) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.track?.album.name + RADIO_SEARCH_KEY + this.track?.artists[0].name)); + return true; + + } else if (action == Actions.ArtistCopyUriToClipboard) { + + copyTextToClipboard(this.track?.artists[0].uri || ""); + return true; + + } else if (action == Actions.ArtistSearchPlaylists) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.track?.artists[0].name)); + return true; + + } else if (action == Actions.ArtistSearchRadio) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.track?.artists[0].name + RADIO_SEARCH_KEY)); + return true; + + } else if (action == Actions.ArtistSearchTracks) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.TRACKS, this.track?.artists[0].name)); + return true; + + } else if (action == Actions.TrackCopyUriToClipboard) { + + copyTextToClipboard(this.track?.uri || ""); + return true; + + } else if (action == Actions.TrackSearchPlaylists) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.track?.name + " " + this.track?.artists[0].name)); + return true; + + } else if (action == Actions.TrackSearchRadio) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.track?.name + RADIO_SEARCH_KEY + this.track?.artists[0].name)); + return true; + + } + // show progress indicator. this.progressShow(); diff --git a/src/components/player-controls.ts b/src/components/player-controls.ts index 7430dd6..8487cc2 100644 --- a/src/components/player-controls.ts +++ b/src/components/player-controls.ts @@ -123,31 +123,28 @@ class PlayerControls extends LitElement { .player-volume-container { display: block; - height: 2.5rem; } .icons { justify-content: center; display: inline-flex; align-items: center; - --mdc-icon-button-size: 2.5rem !important; - --mdc-icon-size: 1.75rem !important; mix-blend-mode: screen; overflow: hidden; text-shadow: 0 0 2px var(--spc-player-palette-vibrant); color: white; + --mdc-icon-button-size: var(--spc-player-controls-icon-button-size, 2.75rem); + --mdc-icon-size: var(--spc-player-controls-icon-size, 2.0rem); } .iconsPower { justify-content: center; display: block; align-items: center; - --mdc-icon-button-size: 2.5rem !important; - --mdc-icon-size: 2.5rem !important; overflow: hidden; color: white; - /* mix-blend-mode: screen; */ - /* text-shadow: 0 0 2px var(--spc-player-palette-vibrant); */ + --mdc-icon-button-size: var(--spc-player-controls-icon-button-size, 3.25rem); + --mdc-icon-size: var(--spc-player-controls-icon-size, 2.5rem); } *[hide] { @@ -293,11 +290,15 @@ class PlayerControls extends LitElement { // if body is displayed, then request a queue items refresh. if (this.isQueueItemsVisible) { - if (debuglog.enabled) { - debuglog("onClickAction - calling for refresh of queue items"); - } + debuglog("toggleDisplayPlayerBodyQueue - calling for refresh of queue items"); elmBody.refreshQueueItems(); + } else { + debuglog("toggleDisplayPlayerBodyQueue - queue items not visible; isQueueItemsVisible = %s", + JSON.stringify(this.isQueueItemsVisible), + ); } + } else { + debuglog("toggleDisplayPlayerBodyQueue - could not find queue items #elmPlayerBodyQueue selector!"); } } @@ -344,10 +345,14 @@ class PlayerControls extends LitElement { // toggle action favorites visibility. this.isActionFavoritesVisible = !this.isActionFavoritesVisible; + if (debuglog.enabled) { + debuglog("update - action favorites toggled - isActionFavoritesVisible = %s", + JSON.stringify(this.isActionFavoritesVisible), + ); + } - debuglog("%c update - action favorites toggled"); if (this.isQueueItemsVisible) { - debuglog("%c update - queue items visible; imeediately closing queue items, and delay opening action favorites"); + debuglog("update - queue items visible; imediately closing queue items, and delay opening action favorites"); // close the queue items body. this.isQueueItemsVisible = false; this.toggleDisplayPlayerBodyQueue(); @@ -356,7 +361,7 @@ class PlayerControls extends LitElement { this.toggleDisplayActionFavorites(); }, 250); } else { - debuglog("%c update - queue items not visible; immediately toggling action favorites"); + debuglog("update - queue items not visible; immediately toggling action favorites"); // show the action favorites body, since the queue items is closed. this.toggleDisplayActionFavorites(); } @@ -374,10 +379,14 @@ class PlayerControls extends LitElement { // toggle queue items visibility. this.isQueueItemsVisible = !this.isQueueItemsVisible; + if (debuglog.enabled) { + debuglog("update - queue items toggled - isQueueItemsVisible = %s", + JSON.stringify(this.isQueueItemsVisible), + ); + } - debuglog("%c update - queue items toggled"); if (this.isActionFavoritesVisible) { - debuglog("%c update - action favorites visible; imeediately closing action favorites, and delay opening queue items"); + debuglog("update - action favorites visible; imeediately closing action favorites, and delay opening queue items"); // close the action favorites body. this.isActionFavoritesVisible = false; this.toggleDisplayActionFavorites(); @@ -386,7 +395,7 @@ class PlayerControls extends LitElement { this.toggleDisplayPlayerBodyQueue(); }, 250); } else { - debuglog("%c update - action favorites not visible; immediately opening queue items"); + debuglog("update - action favorites not visible; immediately opening queue items"); // show the queue items, since the action favorites body is closed. this.toggleDisplayPlayerBodyQueue(); } @@ -439,6 +448,7 @@ class PlayerControls extends LitElement { } + this.progressHide(); return true; } @@ -446,14 +456,11 @@ class PlayerControls extends LitElement { // set alert error message. this.alertErrorSet("Control action failed: \n" + (error as Error).message); + this.progressHide(); return true; } finally { - - // hide progress indicator. - this.progressHide(); - } } diff --git a/src/components/player-volume.ts b/src/components/player-volume.ts index 3429215..e96e454 100644 --- a/src/components/player-volume.ts +++ b/src/components/player-volume.ts @@ -348,13 +348,13 @@ class Volume extends LitElement { justify-content: center; display: inline-flex; align-items: center; - --mdc-icon-button-size: 2.5rem !important; - --mdc-icon-size: 1.75rem !important; mix-blend-mode: screen; overflow: hidden; text-shadow: 0 0 2px var(--spc-player-palette-vibrant); color: white; width: 100%; + --mdc-icon-button-size: var(--spc-player-controls-icon-button-size, 2.75rem); + --mdc-icon-size: var(--spc-player-controls-icon-size, 2.0rem); } *[hide] { diff --git a/src/components/playlist-actions.ts b/src/components/playlist-actions.ts index c769a15..341d6b9 100644 --- a/src/components/playlist-actions.ts +++ b/src/components/playlist-actions.ts @@ -1,11 +1,15 @@ // lovelace card imports. import { css, html, TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; +import copyTextToClipboard from 'copy-text-to-clipboard'; import { + mdiBackupRestore, + mdiClipboardPlusOutline, + mdiDotsHorizontal, mdiHeart, mdiHeartOutline, - mdiPlay, mdiPlaylistPlay, + mdiTrashCanOutline, } from '@mdi/js'; // our imports. @@ -16,19 +20,22 @@ import { FavActionsBase } from './fav-actions-base'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; import { formatDateHHMMSSFromMilliseconds, unescapeHtml } from '../utils/utils'; +import { openWindowNewTab } from '../utils/media-browser-utils.js'; import { GetPlaylistPagePlaylistTracks } from '../types/spotifyplus/playlist-page.js'; import { IPlaylistSimplified } from '../types/spotifyplus/playlist-simplified.js'; import { IPlaylistTrack } from '../types/spotifyplus/playlist-track.js'; -import { openWindowNewTab } from '../utils/media-browser-utils.js'; /** * Playlist actions. */ enum Actions { + PlaylistCopyUriToClipboard = "PlaylistCopyUriToClipboard", + PlaylistDelete = "PlaylistDelete", PlaylistFavoriteAdd = "PlaylistFavoriteAdd", PlaylistFavoriteRemove = "PlaylistFavoriteRemove", PlaylistFavoriteUpdate = "PlaylistFavoriteUpdate", PlaylistItemsUpdate = "PlaylistItemsUpdate", + PlaylistRecoverWebUI = "PlaylistRecoverWebUI", } @@ -98,6 +105,28 @@ class PlaylistActions extends FavActionsBase {
`; + // define dropdown menu actions - playlist. + const actionsPlaylistHtml = html` + + + + + this.onClickAction(Actions.PlaylistRecoverWebUI)}> + +
Recover Playlist via Spotify Web UI
+
+ this.onClickAction(Actions.PlaylistDelete)}> + +
Delete (unfollow) Playlist
+
+ + this.onClickAction(Actions.PlaylistCopyUriToClipboard)}> + +
Copy Playlist URI to Clipboard
+
+
+ `; + // render html. return html`
@@ -110,6 +139,9 @@ class PlaylistActions extends FavActionsBase { ${iconPlaylist} ${this.mediaItem.name} ${(this.isPlaylistFavorite ? actionPlaylistFavoriteRemove : actionPlaylistFavoriteAdd)} + + ${actionsPlaylistHtml} +
@@ -147,9 +179,9 @@ class PlaylistActions extends FavActionsBase {
Duration
${this.playlistTracks?.map((item, index) => html` this.onClickMediaItem(item.track)} + .path=${mdiPlaylistPlay} + .label="Add track "${item.track.name}" to Play Queue" + @click=${() => this.AddPlayerQueueItem(item.track)} slot="icon-button" > 
${index + 1}
@@ -230,28 +262,60 @@ class PlaylistActions extends FavActionsBase { return true; } - // show progress indicator. - this.progressShow(); + try { + + // process actions that don't require a progress indicator. + if (action == Actions.PlaylistCopyUriToClipboard) { + + copyTextToClipboard(this.mediaItem.uri); + return true; + + } else if (action == Actions.PlaylistRecoverWebUI) { + + openWindowNewTab("https://www.spotify.com/us/account/recover-playlists/"); + return true; + + } + + // show progress indicator. + this.progressShow(); + + // call service based on requested action, and refresh affected action component. + if (action == Actions.PlaylistDelete) { - // call service based on requested action, and refresh affected action component. - if (action == Actions.PlaylistFavoriteAdd) { + await this.spotifyPlusService.UnfollowPlaylist(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.PlaylistFavoriteUpdate]); - await this.spotifyPlusService.FollowPlaylist(this.player.id, this.mediaItem.id); - this.updateActions(this.player, [Actions.PlaylistFavoriteUpdate]); + } else if (action == Actions.PlaylistFavoriteAdd) { - } else if (action == Actions.PlaylistFavoriteRemove) { + await this.spotifyPlusService.FollowPlaylist(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.PlaylistFavoriteUpdate]); - await this.spotifyPlusService.UnfollowPlaylist(this.player.id, this.mediaItem.id); - this.updateActions(this.player, [Actions.PlaylistFavoriteUpdate]); + } else if (action == Actions.PlaylistFavoriteRemove) { - } else { + await this.spotifyPlusService.UnfollowPlaylist(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.PlaylistFavoriteUpdate]); - // no action selected - hide progress indicator. + } else { + + // no action selected - hide progress indicator. + this.progressHide(); + + } + + return true; + } + catch (error) { + + // clear the progress indicator and set alert error message. this.progressHide(); + this.alertErrorSet("Action failed: \n" + (error as Error).message); + return true; } + finally { + } - return true; } diff --git a/src/components/show-actions.ts b/src/components/show-actions.ts index 69bf628..28fceeb 100644 --- a/src/components/show-actions.ts +++ b/src/components/show-actions.ts @@ -1,10 +1,13 @@ // lovelace card imports. import { css, html, TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; +import copyTextToClipboard from 'copy-text-to-clipboard'; import { + mdiClipboardPlusOutline, + mdiDotsHorizontal, mdiHeart, mdiHeartOutline, - mdiPlay, + mdiPlaylistPlay, mdiPodcast, } from '@mdi/js'; @@ -15,21 +18,25 @@ import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; import { FavActionsBase } from './fav-actions-base'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; +import { SearchMediaTypes } from '../types/search-media-types'; +import { SearchMediaEvent } from '../events/search-media'; +import { formatDateHHMMSSFromMilliseconds, unescapeHtml } from '../utils/utils'; +import { openWindowNewTab } from '../utils/media-browser-utils'; import { GetResumeInfo } from '../types/spotifyplus/resume-point'; import { GetCopyrights } from '../types/spotifyplus/copyright'; import { IShowSimplified } from '../types/spotifyplus/show-simplified'; -import { formatDateHHMMSSFromMilliseconds, unescapeHtml } from '../utils/utils'; import { IEpisodePageSimplified } from '../types/spotifyplus/episode-page-simplified'; -import { openWindowNewTab } from '../utils/media-browser-utils'; /** * Show actions. */ enum Actions { + ShowCopyUriToClipboard = "ShowCopyUriToClipboard", + ShowEpisodesUpdate = "ShowEpisodesUpdate", ShowFavoriteAdd = "ShowFavoriteAdd", ShowFavoriteRemove = "ShowFavoriteRemove", ShowFavoriteUpdate = "ShowFavoriteUpdate", - ShowEpisodesUpdate = "ShowEpisodesUpdate", + ShowSearchEpisodes = "ShowSearchEpisodes", } @@ -99,6 +106,25 @@ class ShowActions extends FavActionsBase {
`; + // define dropdown menu actions - show. + const actionsShowHtml = html` + + + + + this.onClickAction(Actions.ShowSearchEpisodes)} hide=${this.hideSearchType(SearchMediaTypes.EPISODES)}> + +
Search for Show Episodes
+
+ + this.onClickAction(Actions.ShowCopyUriToClipboard)}> + +
Copy Show URI to Clipboard
+
+ +
+ `; + // render html. // mediaItem will be an IShow object when displaying favorites. // mediaItem will be an IShowSimplified object when displaying search results, @@ -113,6 +139,9 @@ class ShowActions extends FavActionsBase { ${iconShow} ${this.mediaItem.name} ${(this.isShowFavorite ? actionShowFavoriteRemove : actionShowFavoriteAdd)} + + ${actionsShowHtml} +
# Episodes
@@ -141,9 +170,9 @@ class ShowActions extends FavActionsBase {
Duration
${this.showEpisodes?.items.map((item) => html` this.onClickMediaItem(item)} + .path=${mdiPlaylistPlay} + .label="Add episode "${item.name}" to Play Queue" + @click=${() => this.AddPlayerQueueItem(item)} slot="icon-button" > 
${item.name}
@@ -210,28 +239,55 @@ class ShowActions extends FavActionsBase { return true; } - // show progress indicator. - this.progressShow(); + try { + + // process actions that don't require a progress indicator. + if (action == Actions.ShowCopyUriToClipboard) { + + copyTextToClipboard(this.mediaItem.uri); + return true; + + } else if (action == Actions.ShowSearchEpisodes) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.EPISODES, this.mediaItem.name)); + return true; + + } + + // show progress indicator. + this.progressShow(); + + // call service based on requested action, and refresh affected action component. + if (action == Actions.ShowFavoriteAdd) { + + await this.spotifyPlusService.SaveShowFavorites(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.ShowFavoriteUpdate]); - // call service based on requested action, and refresh affected action component. - if (action == Actions.ShowFavoriteAdd) { + } else if (action == Actions.ShowFavoriteRemove) { - await this.spotifyPlusService.SaveShowFavorites(this.player.id, this.mediaItem.id); - this.updateActions(this.player, [Actions.ShowFavoriteUpdate]); + await this.spotifyPlusService.RemoveShowFavorites(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.ShowFavoriteUpdate]); - } else if (action == Actions.ShowFavoriteRemove) { + } else { - await this.spotifyPlusService.RemoveShowFavorites(this.player.id, this.mediaItem.id); - this.updateActions(this.player, [Actions.ShowFavoriteUpdate]); + // no action selected - hide progress indicator. + this.progressHide(); + + } - } else { + return true; + } + catch (error) { - // no action selected - hide progress indicator. + // clear the progress indicator and set alert error message. this.progressHide(); + this.alertErrorSet("Action failed: \n" + (error as Error).message); + return true; } + finally { + } - return true; } diff --git a/src/components/track-actions.ts b/src/components/track-actions.ts index 25f511d..ef7d009 100644 --- a/src/components/track-actions.ts +++ b/src/components/track-actions.ts @@ -1,12 +1,18 @@ // lovelace card imports. import { css, html, TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; +import copyTextToClipboard from 'copy-text-to-clipboard'; import { mdiAccountMusic, mdiAlbum, + mdiClipboardPlusOutline, + mdiDotsHorizontal, mdiHeart, mdiHeartOutline, mdiMusic, + mdiPlaylistMusic, + mdiPlaylistPlay, + mdiRadio, } from '@mdi/js'; // our imports. @@ -16,23 +22,36 @@ import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; import { FavActionsBase } from './fav-actions-base'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; +import { SearchMediaTypes } from '../types/search-media-types'; +import { SearchMediaEvent } from '../events/search-media'; import { formatDateHHMMSSFromMilliseconds } from '../utils/utils'; +import { openWindowNewTab } from '../utils/media-browser-utils'; +import { RADIO_SEARCH_KEY } from '../constants.js'; import { ITrack } from '../types/spotifyplus/track'; -import { openWindowNewTab } from '../utils/media-browser-utils.js'; /** * Track actions. */ enum Actions { + AlbumCopyUriToClipboard = "AlbumCopyUriToClipboard", AlbumFavoriteAdd = "AlbumFavoriteAdd", AlbumFavoriteRemove = "AlbumFavoriteRemove", AlbumFavoriteUpdate = "AlbumFavoriteUpdate", + AlbumSearchRadio = "AlbumSearchRadio", + ArtistCopyUriToClipboard = "ArtistCopyUriToClipboard", ArtistFavoriteAdd = "ArtistFavoriteAdd", ArtistFavoriteRemove = "ArtistFavoriteRemove", ArtistFavoriteUpdate = "ArtistFavoriteUpdate", + ArtistSearchPlaylists = "ArtistSearchPlaylists", + ArtistSearchRadio = "ArtistSearchRadio", + ArtistSearchTracks = "ArtistSearchTracks", + TrackCopyUriToClipboard = "TrackCopyUriToClipboard", TrackFavoriteAdd = "TrackFavoriteAdd", TrackFavoriteRemove = "TrackFavoriteRemove", TrackFavoriteUpdate = "TrackFavoriteUpdate", + TrackPlayQueueAdd = "TrackPlayQueueAdd", + TrackSearchPlaylists = "TrackSearchPlaylists", + TrackSearchRadio = "TrackSearchRadio", } @@ -169,6 +188,77 @@ class TrackActions extends FavActionsBase {
`; + // define dropdown menu actions - track. + const actionsTrackHtml = html` + + + + + this.onClickAction(Actions.TrackSearchPlaylists)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}> + +
Search Playlists for Track
+
+ this.onClickAction(Actions.TrackSearchRadio)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}> + +
Search for Track Radio
+
+ + this.onClickAction(Actions.TrackPlayQueueAdd)}> + +
Add Track to Play Queue
+
+ + this.onClickAction(Actions.TrackCopyUriToClipboard)}> + +
Copy Track URI to Clipboard
+
+
+ `; + + // define dropdown menu actions - album. + const actionsAlbumHtml = html` + + + + + this.onClickAction(Actions.AlbumSearchRadio)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}> + +
Search for Album Radio
+
+ + this.onClickAction(Actions.AlbumCopyUriToClipboard)}> + +
Copy Album URI to Clipboard
+
+
+ `; + + // define dropdown menu actions - artist. + const actionsArtistHtml = html` + + + + + this.onClickAction(Actions.ArtistSearchPlaylists)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}> + +
Search Playlists for Artist
+
+ this.onClickAction(Actions.ArtistSearchTracks)} hide=${this.hideSearchType(SearchMediaTypes.TRACKS)}> + +
Search Tracks for Artist
+
+ this.onClickAction(Actions.ArtistSearchRadio)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}> + +
Search for Artist Radio
+
+ + this.onClickAction(Actions.ArtistCopyUriToClipboard)}> + +
Copy Artist URI to Clipboard
+
+
+ `; + // render html. // note that mediaItem could be an IAlbum or IAlbumSimplified object. return html` @@ -181,16 +271,25 @@ class TrackActions extends FavActionsBase { ${iconTrack} ${this.mediaItem.name} ${(this.isTrackFavorite ? actionTrackFavoriteRemove : actionTrackFavoriteAdd)} + + ${actionsTrackHtml} +
${iconAlbum} ${this.mediaItem.album.name} ${(this.isAlbumFavorite ? actionAlbumFavoriteRemove : actionAlbumFavoriteAdd)} + + ${actionsAlbumHtml} +
${iconArtist} ${this.mediaItem.artists[0].name} ${(this.isArtistFavorite ? actionArtistFavoriteRemove : actionArtistFavoriteAdd)} + + ${actionsArtistHtml} +
Track #
@@ -276,48 +375,116 @@ class TrackActions extends FavActionsBase { return true; } - // show progress indicator. - this.progressShow(); + try { + + // process actions that don't require a progress indicator. + if (action == Actions.AlbumCopyUriToClipboard) { + + copyTextToClipboard(this.mediaItem.album.uri); + return true; + + } else if (action == Actions.AlbumSearchRadio) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.mediaItem.album.name + RADIO_SEARCH_KEY + this.mediaItem.artists[0].name)); + return true; + + } else if (action == Actions.ArtistCopyUriToClipboard) { + + copyTextToClipboard(this.mediaItem.artists[0].uri); + return true; + + } else if (action == Actions.ArtistSearchPlaylists) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.mediaItem.artists[0].name)); + return true; - // call service based on requested action, and refresh affected action component. - if (action == Actions.AlbumFavoriteAdd) { + } else if (action == Actions.ArtistSearchRadio) { - await this.spotifyPlusService.SaveAlbumFavorites(this.player.id, this.mediaItem.album.id); - this.updateActions(this.player, [Actions.AlbumFavoriteUpdate]); + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.mediaItem.artists[0].name + RADIO_SEARCH_KEY)); + return true; - } else if (action == Actions.AlbumFavoriteRemove) { + } else if (action == Actions.ArtistSearchTracks) { - await this.spotifyPlusService.RemoveAlbumFavorites(this.player.id, this.mediaItem.album.id); - this.updateActions(this.player, [Actions.AlbumFavoriteUpdate]); + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.TRACKS, this.mediaItem.artists[0].name)); + return true; + + } else if (action == Actions.TrackCopyUriToClipboard) { + + copyTextToClipboard(this.mediaItem.uri); + return true; + + } else if (action == Actions.TrackSearchPlaylists) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.mediaItem.name + " " + this.mediaItem.artists[0].name)); + return true; + + } else if (action == Actions.TrackSearchRadio) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.mediaItem.name + RADIO_SEARCH_KEY + this.mediaItem.artists[0].name)); + return true; + + } + + // show progress indicator. + this.progressShow(); - } else if (action == Actions.ArtistFavoriteAdd) { + // call service based on requested action, and refresh affected action component. + if (action == Actions.AlbumFavoriteAdd) { - await this.spotifyPlusService.FollowArtists(this.player.id, this.mediaItem.artists[0].id); - this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); + await this.spotifyPlusService.SaveAlbumFavorites(this.player.id, this.mediaItem.album.id); + this.updateActions(this.player, [Actions.AlbumFavoriteUpdate]); - } else if (action == Actions.ArtistFavoriteRemove) { + } else if (action == Actions.AlbumFavoriteRemove) { - await this.spotifyPlusService.UnfollowArtists(this.player.id, this.mediaItem.artists[0].id); - this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); + await this.spotifyPlusService.RemoveAlbumFavorites(this.player.id, this.mediaItem.album.id); + this.updateActions(this.player, [Actions.AlbumFavoriteUpdate]); - } else if (action == Actions.TrackFavoriteAdd) { + } else if (action == Actions.ArtistFavoriteAdd) { - await this.spotifyPlusService.SaveTrackFavorites(this.player.id, this.mediaItem.id); - this.updateActions(this.player, [Actions.TrackFavoriteUpdate]); + await this.spotifyPlusService.FollowArtists(this.player.id, this.mediaItem.artists[0].id); + this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); - } else if (action == Actions.TrackFavoriteRemove) { + } else if (action == Actions.ArtistFavoriteRemove) { - await this.spotifyPlusService.RemoveTrackFavorites(this.player.id, this.mediaItem.id); - this.updateActions(this.player, [Actions.TrackFavoriteUpdate]); + await this.spotifyPlusService.UnfollowArtists(this.player.id, this.mediaItem.artists[0].id); + this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); - } else { + } else if (action == Actions.TrackFavoriteAdd) { - // no action selected - hide progress indicator. + await this.spotifyPlusService.SaveTrackFavorites(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.TrackFavoriteUpdate]); + + } else if (action == Actions.TrackFavoriteRemove) { + + await this.spotifyPlusService.RemoveTrackFavorites(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.TrackFavoriteUpdate]); + + } else if (action == Actions.TrackPlayQueueAdd) { + + // have to hide the progress indicator manually since it does not call updateActions. + await this.spotifyPlusService.AddPlayerQueueItems(this.player.id, this.mediaItem.uri, null, false); + this.progressHide(); + + } else { + + // no action selected - hide progress indicator. + this.progressHide(); + + } + + return true; + } + catch (error) { + + // clear the progress indicator and set alert error message. this.progressHide(); + this.alertErrorSet("Action failed: \n" + (error as Error).message); + return true; } + finally { + } - return true; } diff --git a/src/constants.ts b/src/constants.ts index 26fbcd0..5790caa 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,7 +1,7 @@ import { css } from 'lit'; /** current version of the card. */ -export const CARD_VERSION = '1.0.9'; +export const CARD_VERSION = '1.0.10'; /** SpotifyPlus integration domain identifier. */ export const DOMAIN_SPOTIFYPLUS = 'spotifyplus'; @@ -30,13 +30,26 @@ export const ITEM_SELECTED_WITH_HOLD = 'item-selected-with-hold'; /** identifies the show section event. */ export const SHOW_SECTION = 'show-section'; -/** Company branding logo image to display on the card picker */ +/** Company branding logo image to display on the card picker. */ export const BRAND_LOGO_IMAGE_BASE64 = 'data:image/png;base64,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'; -/** Company branding logo image size to display on the card picker */ +/** Company branding logo image size to display on the card picker. */ export const BRAND_LOGO_IMAGE_SIZE = '30%'; +/** Text used to search for radio content. */ +export const RADIO_SEARCH_KEY = " Radio "; + +/** default size of the icons in the Footer area. */ +export const FOOTER_ICON_SIZE_DEFAULT = '1.75rem'; + +/** default color value of the player header / controls background gradient. */ +export const PLAYER_CONTROLS_BACKGROUND_COLOR_DEFAULT = '#000000BB'; + +/** default size of the icons in the Player controls area. */ +export const PLAYER_CONTROLS_ICON_SIZE_DEFAULT = '2.0rem'; + + export const listStyle = css` .list { --mdc-theme-primary: var(--accent-color); diff --git a/src/editor/general-editor.ts b/src/editor/general-editor.ts index 53b39a1..624f429 100644 --- a/src/editor/general-editor.ts +++ b/src/editor/general-editor.ts @@ -4,7 +4,7 @@ import { css, html, TemplateResult } from 'lit'; // our imports. import { BaseEditor } from './base-editor'; import { Section } from '../types/section'; -import { DOMAIN_MEDIA_PLAYER, DOMAIN_SPOTIFYPLUS } from '../constants'; +import { DOMAIN_MEDIA_PLAYER, DOMAIN_SPOTIFYPLUS, FOOTER_ICON_SIZE_DEFAULT } from '../constants'; const CONFIG_SETTINGS_SCHEMA = [ @@ -52,6 +52,14 @@ const CONFIG_SETTINGS_SCHEMA = [ required: false, type: 'string', }, + { + name: 'footerIconSize', + label: 'Size of the icons in the Footer area.', + help: 'default is "' + FOOTER_ICON_SIZE_DEFAULT + '"', + required: false, + type: 'string', + default: FOOTER_ICON_SIZE_DEFAULT, + }, { name: 'width', label: 'Width of the card', diff --git a/src/editor/player-controls-editor.ts b/src/editor/player-controls-editor.ts index ee6e13f..38314fa 100644 --- a/src/editor/player-controls-editor.ts +++ b/src/editor/player-controls-editor.ts @@ -4,9 +4,18 @@ import { css, html, TemplateResult } from 'lit'; // our imports. import { BaseEditor } from './base-editor'; import { Section } from '../types/section'; +import { PLAYER_CONTROLS_ICON_SIZE_DEFAULT } from '../constants'; const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'playerControlsIconSize', + label: 'Size of the icons in the Player controls area.', + help: 'default is "' + PLAYER_CONTROLS_ICON_SIZE_DEFAULT + '"', + required: false, + type: 'string', + default: PLAYER_CONTROLS_ICON_SIZE_DEFAULT, + }, { name: 'playerControlsHideFavorites', label: 'Hide favorite actions control button in the controls area', diff --git a/src/editor/player-header-editor.ts b/src/editor/player-header-editor.ts index 8fe869c..a17aa72 100644 --- a/src/editor/player-header-editor.ts +++ b/src/editor/player-header-editor.ts @@ -4,7 +4,7 @@ import { css, html, TemplateResult } from 'lit'; // our imports. import { BaseEditor } from './base-editor'; import { Section } from '../types/section'; -import { PLAYER_CONTROLS_BACKGROUND_COLOR_DEFAULT } from '../sections/player'; +import { PLAYER_CONTROLS_BACKGROUND_COLOR_DEFAULT } from '../constants'; const CONFIG_SETTINGS_SCHEMA = [ diff --git a/src/editor/player-volume-editor.ts b/src/editor/player-volume-editor.ts index 99dabec..959b093 100644 --- a/src/editor/player-volume-editor.ts +++ b/src/editor/player-volume-editor.ts @@ -4,7 +4,7 @@ import { css, html, TemplateResult } from 'lit'; // our imports. import { BaseEditor } from './base-editor'; import { Section } from '../types/section'; -import { PLAYER_CONTROLS_BACKGROUND_COLOR_DEFAULT } from '../sections/player'; +import { PLAYER_CONTROLS_BACKGROUND_COLOR_DEFAULT } from '../constants'; const CONFIG_SETTINGS_SCHEMA = [ diff --git a/src/events/search-media.ts b/src/events/search-media.ts new file mode 100644 index 0000000..e68c4fa --- /dev/null +++ b/src/events/search-media.ts @@ -0,0 +1,46 @@ +import { DOMAIN_SPOTIFYPLUS } from '../constants'; +import { SearchMediaTypes } from '../types/search-media-types'; + +/** + * Uniquely identifies the event. + * */ +export const SEARCH_MEDIA = DOMAIN_SPOTIFYPLUS + '-card-search-media'; + + +/** + * Event arguments. + */ +export class SearchMediaEventArgs { + + // property storage. + public searchType: SearchMediaTypes; + public searchCriteria: string; + + /** + * Initializes a new instance of the class. + * + * @param searchType Media type to search. + * @param searchCriteria Criteria to search. + */ + constructor() { + this.searchType = SearchMediaTypes.PLAYLISTS; + this.searchCriteria = ""; + } +} + + +/** + * Event constructor. + */ +export function SearchMediaEvent(searchType: SearchMediaTypes, searchCriteria: string | undefined | null) { + + const args = new SearchMediaEventArgs(); + args.searchType = searchType; + args.searchCriteria = (searchCriteria || "").trim(); + + return new CustomEvent(SEARCH_MEDIA, { + bubbles: true, + composed: true, + detail: args, + }); +} diff --git a/src/sections/player.ts b/src/sections/player.ts index c5b3a37..73af683 100644 --- a/src/sections/player.ts +++ b/src/sections/player.ts @@ -19,21 +19,23 @@ import '../components/player-controls'; import '../components/player-volume'; import { CardConfig } from '../types/card-config'; import { Store } from '../model/store'; -import { BRAND_LOGO_IMAGE_BASE64, BRAND_LOGO_IMAGE_SIZE } from '../constants'; import { MediaPlayer } from '../model/media-player'; import { HomeAssistantEx } from '../types/home-assistant-ex'; import { Palette } from '@vibrant/color'; import { isCardInEditPreview } from '../utils/utils'; import { playerAlerts } from '../types/playerAlerts'; +import { + BRAND_LOGO_IMAGE_BASE64, + BRAND_LOGO_IMAGE_SIZE, + PLAYER_CONTROLS_BACKGROUND_COLOR_DEFAULT, + PLAYER_CONTROLS_ICON_SIZE_DEFAULT +} from '../constants'; // debug logging. import Debug from 'debug/src/browser.js'; import { DEBUG_APP_NAME } from '../constants'; const debuglog = Debug(DEBUG_APP_NAME + ":player"); -/** default color value of the player header / controls background gradient. */ -export const PLAYER_CONTROLS_BACKGROUND_COLOR_DEFAULT = '#000000BB'; - @customElement("spc-player") export class Player extends LitElement implements playerAlerts { @@ -85,7 +87,7 @@ export class Player extends LitElement implements playerAlerts { // if favorites disabled then we don't need to display anything in the body. if ((this.config.playerControlsHideFavorites || false) == true) { return (html``); - } else if (this.player.attributes.media_content_type == 'music') { + } else if (this.player.attributes.sp_item_type == 'track') { return (html``); } else if (this.player.attributes.sp_item_type == 'podcast') { return (html``); @@ -99,7 +101,7 @@ export class Player extends LitElement implements playerAlerts { // if play queue disabled then we don't need to display anything in the body. if ((this.config.playerControlsHidePlayQueue || false) == true) { return (html``); - } else if (this.player.attributes.media_content_type == 'music') { + } else if (this.player.attributes.sp_item_type == 'track') { return (html``); } else if (this.player.attributes.sp_item_type == 'podcast') { return (html``); @@ -110,7 +112,7 @@ export class Player extends LitElement implements playerAlerts { } })()}
- related styles */ ha-assist-chip { - --ha-assist-chip-container-shape: 10px; + --ha-assist-chip-container-shape: 10px; /* 0px=square corner, 10px=rounded corner */ --ha-assist-chip-container-color: var(--card-background-color); } @@ -326,6 +327,27 @@ export class SearchBrowser extends FavBrowserBase { } + /** + * Toggle action visibility - queue items body. + */ + public searchExecute(args: SearchMediaEventArgs): void { + + if (debuglog.enabled) { + debuglog("searchExecute - searching Spotify media:\n%s", + JSON.stringify(args,null,2), + ); + } + + // prepare to search. + this.initSearchValues(args.searchType); + this.filterCriteria = args.searchCriteria; + + // execute the search. + this.updateMediaList(this.player); + + } + + /** * Loads values from persistant storage. */ @@ -372,15 +394,33 @@ export class SearchBrowser extends FavBrowserBase { } + /** + * Handles the click event of a search type menu item. + * + * @param ev Event arguments (a SearchMediaTypes value). + */ private onSearchMediaTypeChanged(ev) { - // if value did not change then don't bother. - if (this.searchMediaType == ev.currentTarget.value) { + this.initSearchValues(ev.currentTarget.value); + + } + + + /** + * Initializes search fields and results, and prepare to search. + * + * This will set the search media type title, clear the media list results, reset + * scroll position, clear alerts, and hide the actions display area. + */ + private initSearchValues(searchType: string) { + + // if searchType did not change then don't bother. + if (this.searchMediaType == searchType) { return; } - // store changed value. - this.searchMediaType = ev.currentTarget.value; + // store searchType and adjust the title. + this.searchMediaType = searchType; this.searchMediaTypeTitle = SEARCH_FOR_PREFIX + this.searchMediaType; // clear the media list, as the items no longer match the search media type. diff --git a/src/services/spotifyplus-service.ts b/src/services/spotifyplus-service.ts index 62c2bd3..4c36d1f 100644 --- a/src/services/spotifyplus-service.ts +++ b/src/services/spotifyplus-service.ts @@ -21,6 +21,7 @@ import { IAlbumSimplified } from '../types/spotifyplus/album-simplified'; import { IArtist } from '../types/spotifyplus/artist'; import { IArtistInfo } from '../types/spotifyplus/artist-info'; import { IArtistPage } from '../types/spotifyplus/artist-page'; +import { IAudiobook } from '../types/spotifyplus/audiobook'; import { IAudiobookPageSimplified } from '../types/spotifyplus/audiobook-page-simplified'; import { IAudiobookSimplified } from '../types/spotifyplus/audiobook-simplified'; import { IChapter } from '../types/spotifyplus/chapter'; @@ -144,9 +145,13 @@ export class SpotifyPlusService { }] }); - //console.log("CallServiceWithResponse (spotifyplus-service) - Service Response:\n%s", - // JSON.stringify(serviceResponse.response) - //); + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response:\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(serviceResponse.response, null, 2) + ); + } // return the service response data or an empty dictionary if no response data was generated. return JSON.stringify(serviceResponse.response) @@ -222,6 +227,55 @@ export class SpotifyPlusService { //} + /** + * Add one or more items to the end of the user's current Spotify Player playback queue. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param uris A list of Spotify track or episode URIs to add to the queue (spotify:track:6zd8T1PBe9JFHmuVnurdRp, spotify:track:1kWUud3vY5ij5r62zxpTRy); values can be track or episode URIs. All URIs must be of the same type - you cannot mix and match tracks and episodes. An unlimited number of items can be added in one request, but the more items the longer it will take. + * @param device_id The id or name of the Spotify Connect Player device this command is targeting. If not supplied, the user's currently active device is the target. If no device is active (or an '*' is specified), then the SpotifyPlus default device is activated. + * @param verify_device_id True to verify a device id is active; otherwise, false to assume that a device id is already active. Default is True. + * @param delay Time delay (in seconds) to wait AFTER issuing the add request (if necessary). This delay will give the spotify web api time to process the change before another command is issued. Default is 0.15; value range is 0 - 10. + */ + public async AddPlayerQueueItems( + entity_id: string, + uris: string | undefined | null = null, + device_id: string | undefined | null = null, + verify_device_id: boolean | undefined | null = true, + delay: number | null = null, + ): Promise { + + try { + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + uris: uris, + }; + + // update service data parameters (with optional parameters). + if (device_id) + serviceData['device_id'] = device_id; + if (verify_device_id) + serviceData['verify_device_id'] = verify_device_id; + if (delay) + serviceData['delay'] = delay; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'add_player_queue_items', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + /** * Check if one or more albums (or the currently playing album) exists in the current * user's 'Your Library' favorites. @@ -985,6 +1039,139 @@ export class SpotifyPlusService { } + /** + * Get Spotify catalog information about artists similar to a given artist. + * Similarity is based on analysis of the Spotify community's listening history. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param artist_id The Spotify ID of the artist (e.g. 6APm8EjxOHSYM5B4i3vT3q). If omitted, the currently playing artist uri id value is used. + * @param sort_result True to sort the items by name; otherwise, False to leave the items in the same order they were returned in by the Spotify Web API. Default is true. + * @param trimResults True to trim certain fields of the output results that are not required and to conserve memory; otherwise, False to return all fields that were returned in by the Spotify Web API. + * @returns A list of `IArtist` objects. + */ + public async GetArtistRelatedArtists( + entity_id: string, + artist_id: string | undefined | null = null, + sort_result: boolean | null = null, + trimResults: boolean = true, + ): Promise> { + + try { + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + }; + + // update service data parameters (with optional parameters). + if (artist_id) + serviceData['artist_id'] = artist_id; + if (sort_result) + serviceData['sort_result'] = sort_result; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_artist_related_artists', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as Array; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if (responseObj != null) { + responseObj.forEach(item => { + item.images = []; + }) + } + } + + return responseObj; + + } + finally { + } + } + + + /** + * Get Spotify catalog information about an artist's top tracks by country. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param artist_id The Spotify ID of the artist (e.g. 6APm8EjxOHSYM5B4i3vT3q). If omitted, the currently playing artist uri id value is used. + * @param market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param sort_result True to sort the items by name; otherwise, False to leave the items in the same order they were returned in by the Spotify Web API. Default is true. + * @param trimResults True to trim certain fields of the output results that are not required and to conserve memory; otherwise, False to return all fields that were returned in by the Spotify Web API. + * @returns A list of `ITrack` objects. + */ + public async GetArtistTopTracks( + entity_id: string, + artist_id: string | undefined | null = null, + market: string | null = null, + sort_result: boolean | null = null, + trimResults: boolean = true, + ): Promise> { + + try { + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + }; + + // update service data parameters (with optional parameters). + if (artist_id) + serviceData['artist_id'] = artist_id; + if (market) + serviceData['market'] = market; + if (sort_result) + serviceData['sort_result'] = sort_result; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_artist_top_tracks', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as Array; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if (responseObj != null) { + responseObj.forEach(item => { + item.available_markets = []; + item.album.available_markets = [] + item.album.images = [] + }) + } + } + + return responseObj; + + } + finally { + } + } + + /** * Get the current user's followed artists. * @@ -1060,6 +1247,73 @@ export class SpotifyPlusService { } + /** + * Get Spotify catalog information for a single audiobook. + * Audiobooks are only available within the US, UK, Canada, Ireland, New Zealand and Australia markets. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param audiobook_id The Spotify ID for the audiobook (e.g. `74aydHJKgYz3AIq3jjBSv1`). If null, the currently playing audiobook uri id value is used. + * @param market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param trimResults True to trim certain fields of the output results that are not required and to conserve memory; otherwise, False to return all fields that were returned in by the Spotify Web API. + * @returns A `IAudiobook` object that contains the audiobook details. + */ + public async GetAudiobook( + entity_id: string, + audiobook_id: string | undefined | null = null, + market: string | null = null, + trimResults: boolean = true, + ): Promise { + + try { + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + }; + + // update service data parameters (with optional parameters). + if (audiobook_id) + serviceData['audiobook_id'] = audiobook_id; + if (market) + serviceData['market'] = market; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_audiobook', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IAudiobook; + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if (responseObj != null) { + responseObj.available_markets = []; + responseObj.images = [] + responseObj.chapters?.forEach(item => { + item.items?.forEach(chapter => { + chapter.available_markets = []; + chapter.description = 'see html_description'; + chapter.images = []; + }) + }) + } + } + + return responseObj; + + } + finally { + } + } + + /** * Get Spotify catalog information about an audiobook's chapters. * diff --git a/src/styles/shared-styles-fav-actions.js b/src/styles/shared-styles-fav-actions.js index 511fc78..2b2fb4d 100644 --- a/src/styles/shared-styles-fav-actions.js +++ b/src/styles/shared-styles-fav-actions.js @@ -26,6 +26,29 @@ export const sharedStylesFavActions = css` color: white; } + /* style actions 3 dots ("...") dropdown menu */ + .actions-dropdown-menu { + white-space: nowrap; + display: inline-flex; + flex-direction: row; + justify-content: left; + vertical-align: text-top; + --ha-select-height: 2.5rem; /* ha dropdown control height */ + --mdc-menu-item-height: 2.5rem; /* mdc dropdown list item height */ + --mdc-icon-button-size: 2.5rem; /* mdc icon button size */ + --md-menu-item-top-space: 0.5rem; /* top spacing between items */ + --md-menu-item-bottom-space: 0.5rem; /* bottom spacing between items */ + --md-menu-item-one-line-container-height: 2.0rem; /* menu item height */ + } + + /* style actions 3 dots ("...") dropdown menu */ + .actions-dropdown-menu > ha-md-button-menu > ha-assist-chip { + /*--ha-assist-chip-container-color: var(--card-background-color);*/ /* transparent is default. */ + --ha-assist-chip-container-shape: 10px; /* 0px=square corner, 10px=rounded corner */ + --md-assist-chip-trailing-space: 0px; /* no label, so no trailing space */ + --md-assist-chip-container-height: 1.5rem; /* height of the dropdown menu container */ + } + /* style ha-icon-button controls in header actions: icon size, title text */ ha-icon-button[slot="icon-button"] { --mdc-icon-button-size: 30px; @@ -68,6 +91,14 @@ export const sharedStylesFavActions = css` width: 100%; } + *[hide="true"] { + display: none !important; + } + + *[hide="false"] { + display: block !important; + } + *[hide] { display: none; } diff --git a/src/types/card-config.ts b/src/types/card-config.ts index 773834d..3c2b90c 100644 --- a/src/types/card-config.ts +++ b/src/types/card-config.ts @@ -30,6 +30,12 @@ export interface CardConfig extends LovelaceCardConfig { */ title?: string; + /** + * Size of the icons in the Footer controls area. + * Default is '2rem'. + */ + footerIconSize?: string; + /** * Width of the card (in 'rem' units). * A value of "fill" can also be used (requires manual editing) to use 100% of @@ -342,6 +348,12 @@ export interface CardConfig extends LovelaceCardConfig { */ playerControlsBackgroundColor?: string; + /** + * Size of the icons in the Player controls area. + * Default is '2.0rem'. + */ + playerControlsIconSize?: string; + /** * Hide mute button in the volume controls area of the Player section form. * Default is false. diff --git a/src/types/spotifyplus/artist.ts b/src/types/spotifyplus/artist.ts index 6932664..5ea3628 100644 --- a/src/types/spotifyplus/artist.ts +++ b/src/types/spotifyplus/artist.ts @@ -45,3 +45,29 @@ export interface IArtist extends IArtistSimplified { popularity: number; } + + +/** + * Gets a user-friendly description of the `genres` object(s). + * + * @param mediaItem Media item that contains a genres property. + * @returns A string that contains a user-friendly description of the genres. + */ +export function GetGenres(mediaItem: any | undefined, delimiter: string | null = null): string { + + if (delimiter == null) + delimiter = "; "; + + let result = ""; + if (mediaItem) { + for (const item of mediaItem.genres || []) { + if ((item != null) && ((item.length) > 0)) { + if (result.length > 0) + result += delimiter; + result += item; + } + } + } + + return result +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 8021fe2..ff907c9 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -2,6 +2,7 @@ import { CardConfig } from '../types/card-config'; import { ConfigArea } from '../types/config-area'; import { Section } from '../types/section'; +import copy from 'copy-text-to-clipboard'; // debug logging. import Debug from 'debug/src/browser.js'; @@ -474,3 +475,25 @@ export const loadHaFormLazyControls = async () => { } } + + +/** + * Copy div.innerText value to clipboard. + * + * @param elm DIV element whose contents are to be copied. + * @returns true if text was copied to the clipboard successfully; otherwise, false. + * + * example usage: + *
This text will be copied
+ */ +export function copyToClipboard(ev): boolean { + const elm = ev.currentTarget as HTMLDivElement; + const result = copy(elm.innerText); + if (debuglog.enabled) { + debuglog("copyToClipboard - text copied to clipboard:\n%s", + JSON.stringify(elm.innerText), + ); + } + window.status = "text copied to clipboard"; + return result; +}