From 862445dfeb7db3bd1fad8bf59a5c90c59182ed58 Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Mon, 4 Nov 2024 10:00:36 -0600 Subject: [PATCH 01/17] [ 1.0.11 ] * Fixed a bug in all media list rendering controls that was causing the media list not to render for some browser types (Fire HD, iPad Air, etc). * Replaced all `lastupdatedon` properties with `date_last_refreshed` property that is populated by the spotifywebapiPython package. --- CHANGELOG.md | 5 + src/components/artist-actions.ts | 11 + src/components/media-browser-icons.ts | 34 +- src/components/media-browser-list.ts | 7 +- src/constants.ts | 2 +- src/sections/album-fav-browser.ts | 2 +- src/sections/artist-fav-browser.ts | 2 +- src/sections/audiobook-fav-browser.ts | 2 +- src/sections/device-browser.ts | 2 +- src/sections/episode-fav-browser.ts | 2 +- src/sections/playlist-fav-browser.ts | 2 +- src/sections/recent-browser.ts | 2 +- src/sections/search-media-browser.ts | 2 +- src/sections/show-fav-browser.ts | 2 +- src/sections/track-fav-browser.ts | 2 +- src/services/spotifyplus-service.ts | 361 ++++++++++++++---- src/types/spotifyplus/album-page-saved.ts | 7 - .../spotifyplus/album-page-simplified.ts | 6 - src/types/spotifyplus/artist-page.ts | 6 - .../spotifyplus/audiobook-page-simplified.ts | 7 - .../spotifyplus/chapter-page-simplified.ts | 7 - src/types/spotifyplus/episode-page-saved.ts | 7 - .../spotifyplus/episode-page-simplified.ts | 7 - src/types/spotifyplus/play-history-page.ts | 7 - src/types/spotifyplus/play-history.ts | 6 - src/types/spotifyplus/player-queue-info.ts | 2 +- .../spotifyplus/playlist-page-simplified.ts | 7 - src/types/spotifyplus/playlist-page.ts | 7 - src/types/spotifyplus/show-page-saved.ts | 7 - src/types/spotifyplus/show-page-simplified.ts | 7 - .../spotifyplus/spotify-connect-devices.ts | 7 - src/types/spotifyplus/track-page-saved.ts | 7 - src/types/spotifyplus/track-page.ts | 6 - 33 files changed, 326 insertions(+), 224 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f124040..d535838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.11 ] - 2024/11/04 + + * Fixed a bug in all media list rendering controls that was causing the media list not to render for some browser types (Fire HD, iPad Air, etc). + * Replaced all `lastupdatedon` properties with `date_last_refreshed` property that is populated by the spotifywebapiPython package. + ###### [ 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. diff --git a/src/components/artist-actions.ts b/src/components/artist-actions.ts index 820836b..41af640 100644 --- a/src/components/artist-actions.ts +++ b/src/components/artist-actions.ts @@ -4,6 +4,7 @@ import { property, state } from 'lit/decorators.js'; import copyTextToClipboard from 'copy-text-to-clipboard'; import { mdiAccountMusic, + mdiAlbum, mdiClipboardPlusOutline, mdiHeart, mdiHeartOutline, @@ -36,6 +37,7 @@ enum Actions { ArtistFavoriteAdd = "ArtistFavoriteAdd", ArtistFavoriteRemove = "ArtistFavoriteRemove", ArtistFavoriteUpdate = "ArtistFavoriteUpdate", + ArtistSearchAlbums = "ArtistSearchAlbums", ArtistSearchPlaylists = "ArtistSearchPlaylists", ArtistSearchRadio = "ArtistSearchRadio", ArtistSearchTracks = "ArtistSearchTracks", @@ -114,6 +116,10 @@ class ArtistActions extends FavActionsBase { + this.onClickAction(Actions.ArtistSearchAlbums)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Search for Artist Albums
+
this.onClickAction(Actions.ArtistSearchPlaylists)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}>
Search Playlists for Artist
@@ -247,6 +253,11 @@ class ArtistActions extends FavActionsBase { copyTextToClipboard(this.mediaItem.uri); return true; + } else if (action == Actions.ArtistSearchAlbums) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ALBUMS, this.mediaItem.name)); + return true; + } else if (action == Actions.ArtistSearchPlaylists) { this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.mediaItem.name)); diff --git a/src/components/media-browser-icons.ts b/src/components/media-browser-icons.ts index 91c34c2..8f16b85 100644 --- a/src/components/media-browser-icons.ts +++ b/src/components/media-browser-icons.ts @@ -38,12 +38,7 @@ export class MediaBrowserIcons extends MediaBrowserBase { // render html. return html` - -
+
${buildMediaBrowserItems(this.items || [], this.config, this.mediaType, this.store).map( (item, index) => html` ${styleMediaBrowserItemBackgroundImage(item.mbi_item.image_url, index, this.mediaType)} @@ -79,33 +74,6 @@ export class MediaBrowserIcons extends MediaBrowserBase { `; } - //${(() => { - // if (this.isTouchDevice) { - // return (html` - // this.onMediaBrowserItemTouchStart(customEvent(ITEM_SELECTED, item))} - // @touchend=${() => this.onMediaBrowserItemTouchEnd(customEvent(ITEM_SELECTED, item))} - // > - // ${renderMediaBrowserItem(item, !item.mbi_item.image_url || !this.hideTitle, !this.hideSubTitle)} - // - // `); - // } else { - // return (html` - // this.onMediaBrowserItemClick(customEvent(ITEM_SELECTED, item))} - // @mousedown=${() => this.onMediaBrowserItemMouseDown()} - // @mouseup=${() => this.onMediaBrowserItemMouseUp(customEvent(ITEM_SELECTED, item))} - // > - // ${renderMediaBrowserItem(item, !item.mbi_item.image_url || !this.hideTitle, !this.hideSubTitle)} - // - // `); - // } - //})()} - /** * Style definitions used by this card section. diff --git a/src/components/media-browser-list.ts b/src/components/media-browser-list.ts index b39525f..58aec90 100644 --- a/src/components/media-browser-list.ts +++ b/src/components/media-browser-list.ts @@ -49,12 +49,7 @@ export class MediaBrowserList extends MediaBrowserBase { // render html. return html` - - + ${buildMediaBrowserItems(this.items || [], this.config, this.mediaType, this.store).map((item, index) => { return html` ${styleMediaBrowserItemBackgroundImage(item.mbi_item.image_url, index, this.mediaType)} diff --git a/src/constants.ts b/src/constants.ts index 5790caa..d1fb9e0 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.10'; +export const CARD_VERSION = '1.0.11'; /** SpotifyPlus integration domain identifier. */ export const DOMAIN_SPOTIFYPLUS = 'spotifyplus'; diff --git a/src/sections/album-fav-browser.ts b/src/sections/album-fav-browser.ts index 8a2e505..692a2f2 100644 --- a/src/sections/album-fav-browser.ts +++ b/src/sections/album-fav-browser.ts @@ -141,7 +141,7 @@ export class AlbumFavBrowser extends FavBrowserBase { // load media list results. this.mediaList = GetAlbums(result); - this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + this.mediaListLastUpdatedOn = result.date_last_refreshed || (Date.now() / 1000); // call base class method, indicating media list update succeeded. super.updatedMediaListOk(); diff --git a/src/sections/artist-fav-browser.ts b/src/sections/artist-fav-browser.ts index 02d12ef..2550a20 100644 --- a/src/sections/artist-fav-browser.ts +++ b/src/sections/artist-fav-browser.ts @@ -139,7 +139,7 @@ export class ArtistFavBrowser extends FavBrowserBase { // load media list results. this.mediaList = result.items; - this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + this.mediaListLastUpdatedOn = result.date_last_refreshed || (Date.now() / 1000); // call base class method, indicating media list update succeeded. super.updatedMediaListOk(); diff --git a/src/sections/audiobook-fav-browser.ts b/src/sections/audiobook-fav-browser.ts index b7f0555..da709c3 100644 --- a/src/sections/audiobook-fav-browser.ts +++ b/src/sections/audiobook-fav-browser.ts @@ -139,7 +139,7 @@ export class AudiobookFavBrowser extends FavBrowserBase { // load media list results. this.mediaList = result.items; - this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + this.mediaListLastUpdatedOn = result.date_last_refreshed || (Date.now() / 1000); // call base class method, indicating media list update succeeded. super.updatedMediaListOk(); diff --git a/src/sections/device-browser.ts b/src/sections/device-browser.ts index 8862f10..018274f 100644 --- a/src/sections/device-browser.ts +++ b/src/sections/device-browser.ts @@ -221,7 +221,7 @@ export class DeviceBrowser extends FavBrowserBase { // load media list results. this.mediaList = result.Items; - this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + this.mediaListLastUpdatedOn = result.DateLastRefreshed || (Date.now() / 1000); // call base class method, indicating media list update succeeded. super.updatedMediaListOk(); diff --git a/src/sections/episode-fav-browser.ts b/src/sections/episode-fav-browser.ts index f2a54fd..a838920 100644 --- a/src/sections/episode-fav-browser.ts +++ b/src/sections/episode-fav-browser.ts @@ -140,7 +140,7 @@ export class EpisodeFavBrowser extends FavBrowserBase { // load media list results. this.mediaList = GetEpisodes(result); - this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + this.mediaListLastUpdatedOn = result.date_last_refreshed || (Date.now() / 1000); // call base class method, indicating media list update succeeded. super.updatedMediaListOk(); diff --git a/src/sections/playlist-fav-browser.ts b/src/sections/playlist-fav-browser.ts index 7ae06d9..bec8e16 100644 --- a/src/sections/playlist-fav-browser.ts +++ b/src/sections/playlist-fav-browser.ts @@ -139,7 +139,7 @@ export class PlaylistFavBrowser extends FavBrowserBase { // load media list results. this.mediaList = result.items; - this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + this.mediaListLastUpdatedOn = result.date_last_refreshed || (Date.now() / 1000); // call base class method, indicating media list update succeeded. super.updatedMediaListOk(); diff --git a/src/sections/recent-browser.ts b/src/sections/recent-browser.ts index 417fd78..e9b2639 100644 --- a/src/sections/recent-browser.ts +++ b/src/sections/recent-browser.ts @@ -139,7 +139,7 @@ export class RecentBrowser extends FavBrowserBase { // load media list results. this.mediaList = GetTracks(result); - this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + this.mediaListLastUpdatedOn = result.date_last_refreshed || (Date.now() / 1000); // call base class method, indicating media list update succeeded. super.updatedMediaListOk(); diff --git a/src/sections/search-media-browser.ts b/src/sections/search-media-browser.ts index 069e76a..290b668 100644 --- a/src/sections/search-media-browser.ts +++ b/src/sections/search-media-browser.ts @@ -490,7 +490,7 @@ export class SearchBrowser extends FavBrowserBase { // load media list results. this.mediaList = result.items as [any]; - this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + this.mediaListLastUpdatedOn = result.date_last_refreshed || (Date.now() / 1000); // clear certain info messsages if they are temporary. if (this.alertInfo?.startsWith("Searching Spotify")) { diff --git a/src/sections/show-fav-browser.ts b/src/sections/show-fav-browser.ts index 4f176f3..94d03b1 100644 --- a/src/sections/show-fav-browser.ts +++ b/src/sections/show-fav-browser.ts @@ -141,7 +141,7 @@ export class ShowFavBrowser extends FavBrowserBase { // load media list results. this.mediaList = GetShows(result); - this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + this.mediaListLastUpdatedOn = result.date_last_refreshed || (Date.now() / 1000); // call base class method, indicating media list update succeeded. super.updatedMediaListOk(); diff --git a/src/sections/track-fav-browser.ts b/src/sections/track-fav-browser.ts index d41a068..61877d2 100644 --- a/src/sections/track-fav-browser.ts +++ b/src/sections/track-fav-browser.ts @@ -141,7 +141,7 @@ export class TrackFavBrowser extends FavBrowserBase { // load media list results. this.mediaList = GetTracks(result); - this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + this.mediaListLastUpdatedOn = result.date_last_refreshed || (Date.now() / 1000); // call base class method, indicating media list update succeeded. super.updatedMediaListOk(); diff --git a/src/services/spotifyplus-service.ts b/src/services/spotifyplus-service.ts index 4c36d1f..596fe12 100644 --- a/src/services/spotifyplus-service.ts +++ b/src/services/spotifyplus-service.ts @@ -145,13 +145,13 @@ export class SpotifyPlusService { }] }); - if (debuglog.enabled) { - debuglog("%cCallServiceWithResponse - Service %s response:\n%s", - "color: orange", - JSON.stringify(serviceRequest.service), - JSON.stringify(serviceResponse.response, null, 2) - ); - } + //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) @@ -726,6 +726,16 @@ export class SpotifyPlusService { } } + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -807,10 +817,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + // return results to caller. return responseObj; } @@ -886,6 +902,16 @@ export class SpotifyPlusService { } } + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -970,10 +996,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + // return results to caller. return responseObj; } @@ -1031,6 +1063,16 @@ export class SpotifyPlusService { } } + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -1095,6 +1137,16 @@ export class SpotifyPlusService { } } + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -1164,6 +1216,16 @@ export class SpotifyPlusService { } } + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -1235,10 +1297,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + // return results to caller. return responseObj; } @@ -1306,6 +1374,16 @@ export class SpotifyPlusService { } } + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -1383,10 +1461,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + // return results to caller. return responseObj; } @@ -1460,10 +1544,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + // return results to caller. return responseObj; } @@ -1529,6 +1619,16 @@ export class SpotifyPlusService { } } + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -1593,6 +1693,16 @@ export class SpotifyPlusService { } } + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -1669,10 +1779,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + // return results to caller. return responseObj; } @@ -1760,8 +1876,18 @@ export class SpotifyPlusService { // set the lastUpdatedOn value to epoch (number of seconds), as the // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + responseObj.date_last_refreshed = responseObj.date_last_refreshed || (Date.now() / 1000); + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -1839,10 +1965,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + // return results to caller. return responseObj; } @@ -1914,10 +2046,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + // return results to caller. return responseObj; } @@ -2012,10 +2150,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + // return results to caller. return responseObj; } @@ -2091,6 +2235,16 @@ export class SpotifyPlusService { }) } + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -2167,10 +2321,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + // return results to caller. return responseObj; } @@ -2248,10 +2408,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + // return results to caller. return responseObj; } @@ -2308,6 +2474,16 @@ export class SpotifyPlusService { } } + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -2385,10 +2561,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + // return results to caller. return responseObj; } @@ -3059,9 +3241,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -3140,9 +3329,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -3223,9 +3419,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -3305,9 +3508,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -3386,9 +3596,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -3469,9 +3686,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } @@ -3554,9 +3778,16 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.lastUpdatedOn = Date.now() / 1000 + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. return responseObj; } diff --git a/src/types/spotifyplus/album-page-saved.ts b/src/types/spotifyplus/album-page-saved.ts index 362826c..55f97f2 100644 --- a/src/types/spotifyplus/album-page-saved.ts +++ b/src/types/spotifyplus/album-page-saved.ts @@ -16,13 +16,6 @@ export interface IAlbumPageSaved extends IPageObject { items: Array; - /** - * Date and time (in epoch format) of when the list was last updated. - * Note that this attribute does not exist in the service response. It was added here for convenience. - */ - lastUpdatedOn?: number; - - /** * Gets a list of all albums contained in the underlying `Items` list. * diff --git a/src/types/spotifyplus/album-page-simplified.ts b/src/types/spotifyplus/album-page-simplified.ts index 90a53c7..7cb0793 100644 --- a/src/types/spotifyplus/album-page-simplified.ts +++ b/src/types/spotifyplus/album-page-simplified.ts @@ -15,12 +15,6 @@ export interface IAlbumPageSimplified extends IPageObject { items: Array; - /** - * Date and time (in epoch format) of when the list was last updated. - * Note that this attribute does not exist in the service response. It was added here for convenience. - */ - lastUpdatedOn?: number; - } diff --git a/src/types/spotifyplus/artist-page.ts b/src/types/spotifyplus/artist-page.ts index ce4df4b..2691836 100644 --- a/src/types/spotifyplus/artist-page.ts +++ b/src/types/spotifyplus/artist-page.ts @@ -15,10 +15,4 @@ export interface IArtistPage extends IPageObject { items: Array; - /** - * Date and time (in epoch format) of when the list was last updated. - * Note that this attribute does not exist in the service response. It was added here for convenience. - */ - lastUpdatedOn?: number; - } diff --git a/src/types/spotifyplus/audiobook-page-simplified.ts b/src/types/spotifyplus/audiobook-page-simplified.ts index ab4ecb2..fdac4a8 100644 --- a/src/types/spotifyplus/audiobook-page-simplified.ts +++ b/src/types/spotifyplus/audiobook-page-simplified.ts @@ -15,13 +15,6 @@ export interface IAudiobookPageSimplified extends IPageObject { items: Array; - /** - * Date and time (in epoch format) of when the list was last updated. - * Note that this attribute does not exist in the service response. It was added here for convenience. - */ - lastUpdatedOn?: number; - - /** * Checks the `Items` collection to see if an item already exists with the * specified Id value. diff --git a/src/types/spotifyplus/chapter-page-simplified.ts b/src/types/spotifyplus/chapter-page-simplified.ts index 09786e6..3fdf632 100644 --- a/src/types/spotifyplus/chapter-page-simplified.ts +++ b/src/types/spotifyplus/chapter-page-simplified.ts @@ -15,13 +15,6 @@ export interface IChapterPageSimplified extends IPageObject { items: Array; - /** - * Date and time (in epoch format) of when the list was last updated. - * Note that this attribute does not exist in the service response. It was added here for convenience. - */ - lastUpdatedOn?: number; - - /** * Checks the `Items` collection to see if an item already exists with the * specified Id value. diff --git a/src/types/spotifyplus/episode-page-saved.ts b/src/types/spotifyplus/episode-page-saved.ts index 91cb703..d77e6bf 100644 --- a/src/types/spotifyplus/episode-page-saved.ts +++ b/src/types/spotifyplus/episode-page-saved.ts @@ -16,13 +16,6 @@ export interface IEpisodePageSaved extends IPageObject { items: Array; - /** - * Date and time (in epoch format) of when the list was last updated. - * Note that this attribute does not exist in the service response. It was added here for convenience. - */ - lastUpdatedOn?: number; - - /** * Gets a list of all episodes contained in the underlying `Items` list. * diff --git a/src/types/spotifyplus/episode-page-simplified.ts b/src/types/spotifyplus/episode-page-simplified.ts index d6d6988..ff84ee1 100644 --- a/src/types/spotifyplus/episode-page-simplified.ts +++ b/src/types/spotifyplus/episode-page-simplified.ts @@ -15,13 +15,6 @@ export interface IEpisodePageSimplified extends IPageObject { items: Array; - /** - * Date and time (in epoch format) of when the list was last updated. - * Note that this attribute does not exist in the service response. It was added here for convenience. - */ - lastUpdatedOn?: number; - - /** * Checks the `Items` collection to see if an item already exists with the * specified Id value. diff --git a/src/types/spotifyplus/play-history-page.ts b/src/types/spotifyplus/play-history-page.ts index 3d1b38c..f0e055b 100644 --- a/src/types/spotifyplus/play-history-page.ts +++ b/src/types/spotifyplus/play-history-page.ts @@ -16,13 +16,6 @@ export interface IPlayHistoryPage extends IPageObject { items: Array; - /** - * Date and time (in epoch format) of when the list was last updated. - * Note that this attribute does not exist in the service response. It was added here for convenience. - */ - lastUpdatedOn?: number; - - /** * Gets a list of all tracks contained in the underlying `Items` list. * diff --git a/src/types/spotifyplus/play-history.ts b/src/types/spotifyplus/play-history.ts index 4d6a5bd..cc36fd0 100644 --- a/src/types/spotifyplus/play-history.ts +++ b/src/types/spotifyplus/play-history.ts @@ -33,10 +33,4 @@ export interface IPlayHistory { track: ITrack; - /** - * Date and time (in epoch format) of when the list was last updated. - * Note that this attribute does not exist in the service response. It was added here for convenience. - */ - lastUpdatedOn?: number; - } diff --git a/src/types/spotifyplus/player-queue-info.ts b/src/types/spotifyplus/player-queue-info.ts index e5421d2..b1e9cdb 100644 --- a/src/types/spotifyplus/player-queue-info.ts +++ b/src/types/spotifyplus/player-queue-info.ts @@ -36,6 +36,6 @@ export interface IPlayerQueueInfo { * Date and time (in epoch format) of when the list was last updated. * Note that this attribute does not exist in the service response. It was added here for convenience. */ - lastUpdatedOn?: number; + date_last_refreshed?: number; } diff --git a/src/types/spotifyplus/playlist-page-simplified.ts b/src/types/spotifyplus/playlist-page-simplified.ts index b7100e0..4be62d5 100644 --- a/src/types/spotifyplus/playlist-page-simplified.ts +++ b/src/types/spotifyplus/playlist-page-simplified.ts @@ -15,13 +15,6 @@ export interface IPlaylistPageSimplified extends IPageObject { items: Array; - /** - * Date and time (in epoch format) of when the list was last updated. - * Note that this attribute does not exist in the service response. It was added here for convenience. - */ - lastUpdatedOn?: number; - - /** * Checks the `Items` collection to see if an item already exists with the * specified Id value. diff --git a/src/types/spotifyplus/playlist-page.ts b/src/types/spotifyplus/playlist-page.ts index d093952..29a172f 100644 --- a/src/types/spotifyplus/playlist-page.ts +++ b/src/types/spotifyplus/playlist-page.ts @@ -16,13 +16,6 @@ export interface IPlaylistPage extends IPageObject { items: Array; - /** - * Date and time (in epoch format) of when the list was last updated. - * Note that this attribute does not exist in the service response. It was added here for convenience. - */ - lastUpdatedOn?: number; - - /** * Gets a list of all tracks contained in the underlying `Items` list. * diff --git a/src/types/spotifyplus/show-page-saved.ts b/src/types/spotifyplus/show-page-saved.ts index c78e2cf..e3a89ea 100644 --- a/src/types/spotifyplus/show-page-saved.ts +++ b/src/types/spotifyplus/show-page-saved.ts @@ -17,13 +17,6 @@ export interface IShowPageSaved extends IPageObject { items: Array; - /** - * Date and time (in epoch format) of when the list was last updated. - * Note that this attribute does not exist in the service response. It was added here for convenience. - */ - lastUpdatedOn?: number; - - /** * Gets a list of all shows contained in the underlying `Items` list. * diff --git a/src/types/spotifyplus/show-page-simplified.ts b/src/types/spotifyplus/show-page-simplified.ts index 2869b4a..684710e 100644 --- a/src/types/spotifyplus/show-page-simplified.ts +++ b/src/types/spotifyplus/show-page-simplified.ts @@ -15,13 +15,6 @@ export interface IShowPageSimplified extends IPageObject { items: Array; - /** - * Date and time (in epoch format) of when the list was last updated. - * Note that this attribute does not exist in the service response. It was added here for convenience. - */ - lastUpdatedOn?: number; - - /** * Checks the `Items` collection to see if an item already exists with the * specified Id value. diff --git a/src/types/spotifyplus/spotify-connect-devices.ts b/src/types/spotifyplus/spotify-connect-devices.ts index 51ff7e0..382f730 100644 --- a/src/types/spotifyplus/spotify-connect-devices.ts +++ b/src/types/spotifyplus/spotify-connect-devices.ts @@ -29,13 +29,6 @@ export interface ISpotifyConnectDevices { ItemsCount: number; - /** - * Date and time (in epoch format) of when the list was last updated. - * Note that this attribute does not exist in the service response. It was added here for convenience. - * Used by SpotifyPlusCard only. - */ - lastUpdatedOn?: number; - } diff --git a/src/types/spotifyplus/track-page-saved.ts b/src/types/spotifyplus/track-page-saved.ts index 56e4073..384ea0d 100644 --- a/src/types/spotifyplus/track-page-saved.ts +++ b/src/types/spotifyplus/track-page-saved.ts @@ -16,13 +16,6 @@ export interface ITrackPageSaved extends IPageObject { items: Array; - /** - * Date and time (in epoch format) of when the list was last updated. - * Note that this attribute does not exist in the service response. It was added here for convenience. - */ - lastUpdatedOn?: number; - - /** * Gets a list of all tracks contained in the underlying `items` list. * diff --git a/src/types/spotifyplus/track-page.ts b/src/types/spotifyplus/track-page.ts index 171aed3..affd48d 100644 --- a/src/types/spotifyplus/track-page.ts +++ b/src/types/spotifyplus/track-page.ts @@ -15,10 +15,4 @@ export interface ITrackPage extends IPageObject { items: Array; - /** - * Date and time (in epoch format) of when the list was last updated. - * Note that this attribute does not exist in the service response. It was added here for convenience. - */ - lastUpdatedOn?: number; - } From 6c7ca6924a9cbbdc04d22b3feeb336e829529e5e Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Fri, 15 Nov 2024 10:18:19 -0600 Subject: [PATCH 02/17] [ 1.0.12 ] * This release requires the SpotifyPlus v1.0.65 release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. * Added category browser: browse Spotify playlists by categories; existing card configurations have to enable the section in the general configuration settings. * Added dynamic track recommendation capability to user-defined presets. Simply put, you define a preset with the parameters of what you want to play and Spotify searches its media catalog for tracks that match. The matching tracks are then added to a play queue and played in random order. The matching tracks will change over time, as Spotify adds new content to its media catalog. * Added action for all playable media types: Copy Preset Info to Clipboard. This will create a user-preset configuration entry for the selected media and copy it to the clipboard; the entry can then be pasted into the configuration editor under the `userPresets:` key, which will create a user preset for the media item. * Updated artist details to show more information about the artist. Note that actions menu can be used to display more artist-related details (albums, top tracks, etc). * Added artist action: show artist albums; lists only the artist albums (no compilations, no appears on, no singles, etc). * Added artist action: show artist album compilations; lists only the artist compilation albums (no appears on, no singles, etc). * Added artist action: show artist albums appears on (aka collaborations); lists only the artist appears on albums (no compilations, no singles, etc). * Added artist action: show artist album singles; lists only the artist single release albums (no compilations, no appears on, etc). * Added artist action: show artist related artists; lists artists that are similar to the selected artist. * Added show action: search show episodes; lists show episodes with cover art for the selected show. * Updated show details form to only display the first 20 episodes of the show after the show description. This will make the UI much more responsive, as most shows have 200+ episodes. More shows can be listed by using the actions menu drop down. --- CHANGELOG.md | 15 + SpotifyPlusCard.njsproj | 20 +- src/card.ts | 22 +- src/components/album-actions.ts | 79 ++- src/components/artist-actions.ts | 196 ++++-- src/components/audiobook-actions.ts | 21 +- src/components/device-actions.ts | 48 +- src/components/episode-actions.ts | 32 +- src/components/fav-actions-base.ts | 30 +- src/components/footer.ts | 8 + src/components/media-browser-base.ts | 93 ++- src/components/media-browser-icons.ts | 6 +- src/components/media-browser-list.ts | 4 +- src/components/player-body-audiobook.ts | 59 +- src/components/player-body-base.ts | 19 +- src/components/player-body-queue.ts | 1 + src/components/player-body-show.ts | 37 +- src/components/player-body-track.ts | 84 ++- src/components/player-controls.ts | 2 +- src/components/playlist-actions.ts | 121 +++- src/components/show-actions.ts | 26 +- src/components/track-actions.ts | 79 ++- src/components/userpreset-actions.ts | 1 + src/constants.ts | 4 +- src/decorators/storage.ts | 1 + src/editor/audiobook-fav-browser-editor.ts | 2 +- src/editor/category-browser-editor.ts | 127 ++++ src/editor/editor-form.ts | 4 +- src/editor/editor.ts | 7 +- src/editor/general-editor.ts | 1 + src/editor/show-fav-browser-editor.ts | 2 +- src/events/search-media.ts | 71 +- src/sections/album-fav-browser.ts | 25 +- src/sections/artist-fav-browser.ts | 25 +- src/sections/audiobook-fav-browser.ts | 25 +- src/sections/category-browser.ts | 401 +++++++++++ src/sections/device-browser.ts | 28 +- src/sections/episode-fav-browser.ts | 27 +- src/sections/fav-browser-base.ts | 67 +- src/sections/playlist-fav-browser.ts | 25 +- src/sections/recent-browser.ts | 27 +- src/sections/search-media-browser.ts | 646 ++++++++++++++++-- src/sections/show-fav-browser.ts | 27 +- src/sections/track-fav-browser.ts | 29 +- src/sections/userpreset-browser.ts | 100 ++- src/services/hass-service.ts | 2 +- src/services/spotifyplus-service.ts | 634 ++++++++++------- src/styles/shared-styles-fav-browser.js | 9 +- src/types/card-config.ts | 40 ++ src/types/config-area.ts | 1 + src/types/search-media-types.ts | 36 +- src/types/section.ts | 1 + .../spotifyplus/artist-info-tour-event.ts | 6 + src/types/spotifyplus/artist-info.ts | 8 +- src/types/spotifyplus/category-page.ts | 18 + src/types/spotifyplus/category.ts | 65 ++ src/types/spotifyplus/page-object.ts | 17 +- src/types/spotifyplus/recommendation-seed.ts | 51 ++ .../track-recommendations-properties.ts | 320 +++++++++ .../spotifyplus/track-recommendations.ts | 21 + src/types/spotifyplus/user-preset.ts | 40 +- src/utils/config-util.ts | 543 +++++++++++++++ src/utils/media-browser-utils.ts | 44 +- src/utils/utils.ts | 62 +- 64 files changed, 3827 insertions(+), 765 deletions(-) create mode 100644 src/editor/category-browser-editor.ts create mode 100644 src/sections/category-browser.ts create mode 100644 src/types/spotifyplus/category-page.ts create mode 100644 src/types/spotifyplus/category.ts create mode 100644 src/types/spotifyplus/recommendation-seed.ts create mode 100644 src/types/spotifyplus/track-recommendations-properties.ts create mode 100644 src/types/spotifyplus/track-recommendations.ts create mode 100644 src/utils/config-util.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d535838..5a7480f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.12 ] - 2024/11/15 + + * This release requires the SpotifyPlus v1.0.65 release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. + * Added category browser: browse Spotify playlists by categories; existing card configurations have to enable the section in the general configuration settings. + * Added dynamic track recommendation capability to user-defined presets. Simply put, you define a preset with the parameters of what you want to play and Spotify searches its media catalog for tracks that match. The matching tracks are then added to a play queue and played in random order. The matching tracks will change over time, as Spotify adds new content to its media catalog. + * Added action for all playable media types: Copy Preset Info to Clipboard. This will create a user-preset configuration entry for the selected media and copy it to the clipboard; the entry can then be pasted into the configuration editor under the `userPresets:` key, which will create a user preset for the media item. + * Updated artist details to show more information about the artist. Note that actions menu can be used to display more artist-related details (albums, top tracks, etc). + * Added artist action: show artist albums; lists only the artist albums (no compilations, no appears on, no singles, etc). + * Added artist action: show artist album compilations; lists only the artist compilation albums (no appears on, no singles, etc). + * Added artist action: show artist albums appears on (aka collaborations); lists only the artist appears on albums (no compilations, no singles, etc). + * Added artist action: show artist album singles; lists only the artist single release albums (no compilations, no appears on, etc). + * Added artist action: show artist related artists; lists artists that are similar to the selected artist. + * Added show action: search show episodes; lists show episodes with cover art for the selected show. + * Updated show details form to only display the first 20 episodes of the show after the show description. This will make the UI much more responsive, as most shows have 200+ episodes. More shows can be listed by using the actions menu drop down. + ###### [ 1.0.11 ] - 2024/11/04 * Fixed a bug in all media list rendering controls that was causing the media list not to render for some browser types (Fire HD, iPad Air, etc). diff --git a/SpotifyPlusCard.njsproj b/SpotifyPlusCard.njsproj index a222fcc..51df37b 100644 --- a/SpotifyPlusCard.njsproj +++ b/SpotifyPlusCard.njsproj @@ -127,9 +127,7 @@ - - Code - + @@ -140,10 +138,10 @@ + - - Code - + + @@ -201,6 +199,8 @@ + + @@ -219,12 +219,11 @@ - - Code - + + @@ -235,6 +234,8 @@ + + @@ -256,6 +257,7 @@ + diff --git a/src/card.ts b/src/card.ts index 30548c7..da29109 100644 --- a/src/card.ts +++ b/src/card.ts @@ -10,6 +10,7 @@ import { when } from 'lit/directives/when.js'; import './sections/album-fav-browser'; // SECTION.ALBUM_FAVORITES import './sections/artist-fav-browser'; // SECTION.ARTIST_FAVORITES import './sections/audiobook-fav-browser'; // SECTION.AUDIOBOOK_FAVORITES +import './sections/category-browser'; // SECTION.CATEGORYS import './sections/device-browser'; // SECTION.DEVICES import './sections/episode-fav-browser'; // SECTION.EPISODE_FAVORITES import './sections/player'; // SECTION.PLAYER @@ -168,6 +169,7 @@ export class Card extends LitElement { [Section.ALBUM_FAVORITES, () => html``], [Section.ARTIST_FAVORITES, () => html``], [Section.AUDIOBOOK_FAVORITES, () => html``], + [Section.CATEGORYS, () => html``], [Section.DEVICES, () => html``], [Section.EPISODE_FAVORITES, () => html``], [Section.PLAYER, () => html``], @@ -484,6 +486,8 @@ export class Card extends LitElement { sectionNew = Section.USERPRESETS; } else if (sectionsConfigured.includes(Section.RECENTS)) { sectionNew = Section.RECENTS; + } else if (sectionsConfigured.includes(Section.CATEGORYS)) { + sectionNew = Section.CATEGORYS; } else if (sectionsConfigured.includes(Section.PLAYLIST_FAVORITES)) { sectionNew = Section.PLAYLIST_FAVORITES; } else if (sectionsConfigured.includes(Section.ALBUM_FAVORITES)) { @@ -664,7 +668,6 @@ export class Card extends LitElement { // show the search section. this.section = Section.SEARCH_MEDIA; this.store.section = this.section; - //this.dispatchEvent(customEvent(SHOW_SECTION, Section.SEARCH_MEDIA)); // wait just a bit before executing the search. setTimeout(() => { @@ -747,6 +750,10 @@ export class Card extends LitElement { newConfig.audiobookFavBrowserItemsHideTitle = newConfig.audiobookFavBrowserItemsHideTitle || false; newConfig.audiobookFavBrowserItemsSortTitle = newConfig.audiobookFavBrowserItemsSortTitle || false; + newConfig.categoryBrowserItemsPerRow = newConfig.categoryBrowserItemsPerRow || 4; + newConfig.categoryBrowserItemsHideTitle = newConfig.categoryBrowserItemsHideTitle || false; + newConfig.categoryBrowserItemsSortTitle = newConfig.categoryBrowserItemsSortTitle || false; + newConfig.deviceBrowserItemsPerRow = newConfig.deviceBrowserItemsPerRow || 1; newConfig.deviceBrowserItemsHideSubTitle = newConfig.deviceBrowserItemsHideSubTitle || false; newConfig.deviceBrowserItemsHideTitle = newConfig.deviceBrowserItemsHideTitle || false; @@ -854,9 +861,9 @@ export class Card extends LitElement { public static getStubConfig(): Record { return { - sections: [Section.PLAYER, Section.ALBUM_FAVORITES, Section.ARTIST_FAVORITES, Section.PLAYLIST_FAVORITES, Section.RECENTS, - Section.DEVICES, Section.TRACK_FAVORITES, Section.USERPRESETS, Section.AUDIOBOOK_FAVORITES, Section.SHOW_FAVORITES, - Section.EPISODE_FAVORITES, Section.SEARCH_MEDIA], + sections: [Section.PLAYER, Section.ALBUM_FAVORITES, Section.ARTIST_FAVORITES, Section.CATEGORYS, Section.PLAYLIST_FAVORITES, + Section.RECENTS, Section.DEVICES, Section.TRACK_FAVORITES, Section.USERPRESETS, Section.AUDIOBOOK_FAVORITES, + Section.SHOW_FAVORITES, Section.EPISODE_FAVORITES, Section.SEARCH_MEDIA], entity: "", playerHeaderTitle: "{player.source}", @@ -885,6 +892,13 @@ export class Card extends LitElement { audiobookFavBrowserItemsHideSubTitle: false, audiobookFavBrowserItemsSortTitle: true, + categoryBrowserTitle: "Categorys for {player.sp_user_display_name} ({medialist.itemcount} items)", + categoryBrowserSubTitle: "click a tile item to view the content; click-hold for actions", + categoryBrowserItemsPerRow: 4, + categoryBrowserItemsHideTitle: false, + categoryBrowserItemsHideSubTitle: true, + categoryBrowserItemsSortTitle: true, + deviceBrowserTitle: "Spotify Connect Devices ({medialist.itemcount} items)", deviceBrowserSubTitle: "click an item to select the device; click-hold for device info", deviceBrowserItemsPerRow: 1, diff --git a/src/components/album-actions.ts b/src/components/album-actions.ts index ce6a8ff..7895029 100644 --- a/src/components/album-actions.ts +++ b/src/components/album-actions.ts @@ -5,6 +5,7 @@ import copyTextToClipboard from 'copy-text-to-clipboard'; import { mdiAccountMusic, mdiAlbum, + mdiBookmarkMusicOutline, mdiClipboardPlusOutline, mdiDotsHorizontal, mdiHeart, @@ -26,8 +27,9 @@ 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 { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD, RADIO_SEARCH_KEY } from '../constants'; import { GetCopyrights } from '../types/spotifyplus/copyright'; +import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; import { IAlbum } from '../types/spotifyplus/album'; import { ITrackPageSimplified } from '../types/spotifyplus/track-page-simplified'; @@ -35,6 +37,7 @@ import { ITrackPageSimplified } from '../types/spotifyplus/track-page-simplified * Album actions. */ enum Actions { + AlbumCopyPresetToClipboard = "AlbumCopyPresetToClipboard", AlbumCopyUriToClipboard = "AlbumCopyUriToClipboard", AlbumFavoriteAdd = "AlbumFavoriteAdd", AlbumFavoriteRemove = "AlbumFavoriteRemove", @@ -49,6 +52,12 @@ enum Actions { ArtistSearchPlaylists = "ArtistSearchPlaylists", ArtistSearchRadio = "ArtistSearchRadio", ArtistSearchTracks = "ArtistSearchTracks", + ArtistShowAlbums = "ArtistShowAlbums", + ArtistShowAlbumsAppearsOn = "ArtistShowAlbumsAppearsOn", + ArtistShowAlbumsCompilation = "ArtistShowAlbumsCompilation", + ArtistShowAlbumsSingle = "ArtistShowAlbumsSingle", + ArtistShowRelatedArtists = "ArtistShowRelatedArtists", + ArtistShowTopTracks = "ArtistShowTopTracks", } @@ -167,6 +176,10 @@ class AlbumActions extends FavActionsBase {
Copy Album URI to Clipboard
+ this.onClickAction(Actions.AlbumCopyPresetToClipboard)}> + +
Copy Album Preset Info to Clipboard
+
`; @@ -189,6 +202,31 @@ class AlbumActions extends FavActionsBase {
Search for Artist Radio
+ this.onClickAction(Actions.ArtistShowTopTracks)} hide=${this.hideSearchType(SearchMediaTypes.TRACKS)}> + +
Show Artist Top Tracks
+
+ this.onClickAction(Actions.ArtistShowAlbums)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Show Artist Albums
+
+ this.onClickAction(Actions.ArtistShowAlbumsCompilation)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Show Artist Albums Compilations
+
+ this.onClickAction(Actions.ArtistShowAlbumsSingle)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Show Artist Albums Singles
+
+ this.onClickAction(Actions.ArtistShowAlbumsAppearsOn)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Show Artist Albums AppearsOn
+
+ this.onClickAction(Actions.ArtistShowRelatedArtists)} hide=${this.hideSearchType(SearchMediaTypes.ARTISTS)}> + +
Show Related Artists
+
+ this.onClickAction(Actions.ArtistCopyUriToClipboard)}>
Copy Artist URI to Clipboard
@@ -203,6 +241,7 @@ class AlbumActions extends FavActionsBase { return html`
${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""}
@@ -322,7 +361,13 @@ class AlbumActions extends FavActionsBase { try { // process actions that don't require a progress indicator. - if (action == Actions.AlbumCopyUriToClipboard) { + if (action == Actions.AlbumCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.mediaItem, this.mediaItem.artists[0].name)); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + + } else if (action == Actions.AlbumCopyUriToClipboard) { copyTextToClipboard(this.mediaItem.uri); return true; @@ -347,6 +392,36 @@ class AlbumActions extends FavActionsBase { this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.mediaItem.artists[0].name + RADIO_SEARCH_KEY)); return true; + } else if (action == Actions.ArtistShowAlbums) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_ALBUMS, this.mediaItem.artists[0].name, this.mediaItem.artists[0].name, this.mediaItem.artists[0].uri)); + return true; + + } else if (action == Actions.ArtistShowAlbumsAppearsOn) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_ALBUMS_APPEARSON, this.mediaItem.artists[0].name, this.mediaItem.artists[0].name, this.mediaItem.artists[0].uri)); + return true; + + } else if (action == Actions.ArtistShowAlbumsCompilation) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_ALBUMS_COMPILATION, this.mediaItem.artists[0].name, this.mediaItem.artists[0].name, this.mediaItem.artists[0].uri)); + return true; + + } else if (action == Actions.ArtistShowAlbumsSingle) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_ALBUMS_SINGLE, this.mediaItem.artists[0].name, this.mediaItem.artists[0].name, this.mediaItem.artists[0].uri)); + return true; + + } else if (action == Actions.ArtistShowRelatedArtists) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_RELATED_ARTISTS, this.mediaItem.artists[0].name, this.mediaItem.artists[0].name, this.mediaItem.artists[0].uri)); + return true; + + } else if (action == Actions.ArtistShowTopTracks) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_TOP_TRACKS, this.mediaItem.artists[0].name, this.mediaItem.artists[0].name, this.mediaItem.artists[0].uri)); + return true; + } else if (action == Actions.ArtistSearchTracks) { this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.TRACKS, this.mediaItem.artists[0].name)); diff --git a/src/components/artist-actions.ts b/src/components/artist-actions.ts index 41af640..920e2de 100644 --- a/src/components/artist-actions.ts +++ b/src/components/artist-actions.ts @@ -5,14 +5,18 @@ import copyTextToClipboard from 'copy-text-to-clipboard'; import { mdiAccountMusic, mdiAlbum, + mdiBookmarkMusicOutline, mdiClipboardPlusOutline, + mdiFacebook, mdiHeart, mdiHeartOutline, + mdiInstagram, mdiDotsHorizontal, mdiMusic, - mdiPlay, mdiPlaylistPlay, mdiRadio, + mdiTwitter, + mdiWikipedia, } from '@mdi/js'; // our imports. @@ -22,25 +26,40 @@ 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'; -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'; +import { openWindowNewTab } from '../utils/media-browser-utils'; +import { unescapeHtml } from '../utils/utils'; +import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; +import { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD } from '../constants'; +import { IArtist, GetGenres } from '../types/spotifyplus/artist'; +import { IArtistInfo } from '../types/spotifyplus/artist-info'; + +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from '../constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":artist-actions"); /** * Artist actions. */ enum Actions { ArtistAlbumsUpdate = "ArtistAlbumsUpdate", + ArtistCopyPresetToClipboard = "ArtistCopyPresetToClipboard", ArtistCopyUriToClipboard = "ArtistCopyUriToClipboard", + ArtistGetInfo = "ArtistGetInfo", ArtistFavoriteAdd = "ArtistFavoriteAdd", ArtistFavoriteRemove = "ArtistFavoriteRemove", ArtistFavoriteUpdate = "ArtistFavoriteUpdate", - ArtistSearchAlbums = "ArtistSearchAlbums", ArtistSearchPlaylists = "ArtistSearchPlaylists", ArtistSearchRadio = "ArtistSearchRadio", ArtistSearchTracks = "ArtistSearchTracks", + ArtistShowAlbums = "ArtistShowAlbums", + ArtistShowAlbumsAppearsOn = "ArtistShowAlbumsAppearsOn", + ArtistShowAlbumsCompilation = "ArtistShowAlbumsCompilation", + ArtistShowAlbumsSingle = "ArtistShowAlbumsSingle", + ArtistShowRelatedArtists = "ArtistShowRelatedArtists", + ArtistShowTopTracks = "ArtistShowTopTracks", } @@ -50,7 +69,7 @@ class ArtistActions extends FavActionsBase { @property({ attribute: false }) mediaItem!: IArtist; // private state properties. - @state() private artistAlbums?: IAlbumPageSimplified; + @state() private artistInfo?: IArtistInfo; @state() private isArtistFavorite?: boolean; @@ -116,10 +135,6 @@ class ArtistActions extends FavActionsBase { - this.onClickAction(Actions.ArtistSearchAlbums)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> - -
Search for Artist Albums
-
this.onClickAction(Actions.ArtistSearchPlaylists)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}>
Search Playlists for Artist
@@ -133,10 +148,39 @@ class ArtistActions extends FavActionsBase {
Search for Artist Radio
+ this.onClickAction(Actions.ArtistShowTopTracks)} hide=${this.hideSearchType(SearchMediaTypes.TRACKS)}> + +
Show Artist Top Tracks
+
+ this.onClickAction(Actions.ArtistShowAlbums)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Show Artist Albums
+
+ this.onClickAction(Actions.ArtistShowAlbumsCompilation)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Show Artist Albums Compilations
+
+ this.onClickAction(Actions.ArtistShowAlbumsSingle)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Show Artist Albums Singles
+
+ this.onClickAction(Actions.ArtistShowAlbumsAppearsOn)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Show Artist Albums AppearsOn
+
+ this.onClickAction(Actions.ArtistShowRelatedArtists)} hide=${this.hideSearchType(SearchMediaTypes.ARTISTS)}> + +
Show Related Artists
+
+ this.onClickAction(Actions.ArtistCopyUriToClipboard)}>
Copy Artist URI to Clipboard
+ this.onClickAction(Actions.ArtistCopyPresetToClipboard)}> + +
Copy Artist Preset Info to Clipboard
+
`; @@ -144,6 +188,7 @@ class ArtistActions extends FavActionsBase { return html`
${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""}
@@ -169,25 +214,47 @@ class ArtistActions extends FavActionsBase {
-
-
 
-
#
-
Title
-
Released
-
Type
- ${this.artistAlbums?.items.map((item, index) => html` + ${(this.artistInfo?.about_url_facebook) ? html` +
this.onClickMediaItem(item)} - slot="icon-button" - >  -
${index + 1}
-
${item.name}
-
${item.release_date}
-
${item.album_type}
- `)} -
+ .path=${mdiFacebook} + .label="View Artist "${this.mediaItem.name}" info on Facebook" + @click=${() => openWindowNewTab(this.artistInfo?.about_url_facebook || "")} + slot="icon-button-small" + > +
+ ` : ""} + ${(this.artistInfo?.about_url_instagram) ? html` +
+ openWindowNewTab(this.artistInfo?.about_url_instagram || "")} + slot="icon-button-small" + > +
+ ` : ""} + ${(this.artistInfo?.about_url_twitter) ? html` +
+ openWindowNewTab(this.artistInfo?.about_url_twitter || "")} + slot="icon-button-small" + > +
+ ` : ""} + ${(this.artistInfo?.about_url_wikipedia) ? html` +
+ openWindowNewTab(this.artistInfo?.about_url_wikipedia || "")} + slot="icon-button-small" + > +
+ ` : ""} +
`; } @@ -248,14 +315,15 @@ class ArtistActions extends FavActionsBase { try { // process actions that don't require a progress indicator. - if (action == Actions.ArtistCopyUriToClipboard) { + if (action == Actions.ArtistCopyPresetToClipboard) { - copyTextToClipboard(this.mediaItem.uri); + copyTextToClipboard(GetUserPresetConfigEntry(this.mediaItem)); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; - } else if (action == Actions.ArtistSearchAlbums) { + } else if (action == Actions.ArtistCopyUriToClipboard) { - this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ALBUMS, this.mediaItem.name)); + copyTextToClipboard(this.mediaItem.uri); return true; } else if (action == Actions.ArtistSearchPlaylists) { @@ -268,6 +336,36 @@ class ArtistActions extends FavActionsBase { this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.mediaItem.name + " Radio")); return true; + } else if (action == Actions.ArtistShowAlbums) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_ALBUMS, this.mediaItem.name, this.mediaItem.name, this.mediaItem.uri)); + return true; + + } else if (action == Actions.ArtistShowAlbumsAppearsOn) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_ALBUMS_APPEARSON, this.mediaItem.name, this.mediaItem.name, this.mediaItem.uri)); + return true; + + } else if (action == Actions.ArtistShowAlbumsCompilation) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_ALBUMS_COMPILATION, this.mediaItem.name, this.mediaItem.name, this.mediaItem.uri)); + return true; + + } else if (action == Actions.ArtistShowAlbumsSingle) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_ALBUMS_SINGLE, this.mediaItem.name, this.mediaItem.name, this.mediaItem.uri)); + return true; + + } else if (action == Actions.ArtistShowRelatedArtists) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_RELATED_ARTISTS, this.mediaItem.name, this.mediaItem.name, this.mediaItem.uri)); + return true; + + } else if (action == Actions.ArtistShowTopTracks) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_TOP_TRACKS, this.mediaItem.name, this.mediaItem.name, this.mediaItem.uri)); + return true; + } else if (action == Actions.ArtistSearchTracks) { this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.TRACKS, this.mediaItem.name)); @@ -334,36 +432,40 @@ class ArtistActions extends FavActionsBase { const promiseRequests = new Array>(); // was this action chosen to be updated? - if ((updateActions.indexOf(Actions.ArtistAlbumsUpdate) != -1) || (updateActions.length == 0)) { + if ((updateActions.indexOf(Actions.ArtistGetInfo) != -1) || (updateActions.length == 0)) { // create promise - get action list data. - const promiseGetArtistAlbums = new Promise((resolve, reject) => { - - const market = null; - const include_groups = "album,appears_on,compilation"; - const limit_total = 300; - const sort_result = true; + const promiseGetArtistInfo = new Promise((resolve, reject) => { - // call service to retrieve artist albums. - this.spotifyPlusService.GetArtistAlbums(player.id, this.mediaItem.id, include_groups, 0, 0, market, limit_total, sort_result) - .then(albums => { + // call service to retrieve artist info. + this.spotifyPlusService.GetArtistInfo(player.id, this.mediaItem.id) + .then(info => { // stash the result into state, and resolve the promise. - this.artistAlbums = albums; + this.artistInfo = info; resolve(true); }) .catch(error => { + if (debuglog.enabled) { + debuglog("updateActions - Get Artist Info failed: " + (error as Error).message); + } + + // clear results, and resolve the promise; we do this, as the GetArtistInfo is an + // experimental feature that may fail. if it does fail, then we gracefully allow it. + this.artistInfo = undefined; + reject(true); + // clear results, and reject the promise. - this.artistAlbums = undefined; - this.alertErrorSet("Get Artist Albums failed: \n" + (error as Error).message); - reject(error); + //this.artistInfo = undefined; + //this.alertErrorSet("Get Artist Info failed: \n" + (error as Error).message); + //reject(error); }) }); - promiseRequests.push(promiseGetArtistAlbums); + promiseRequests.push(promiseGetArtistInfo); } // was this action chosen to be updated? diff --git a/src/components/audiobook-actions.ts b/src/components/audiobook-actions.ts index 30c5037..507f0c6 100644 --- a/src/components/audiobook-actions.ts +++ b/src/components/audiobook-actions.ts @@ -4,6 +4,7 @@ import { property, state } from 'lit/decorators.js'; import copyTextToClipboard from 'copy-text-to-clipboard'; import { mdiAccountDetailsOutline, + mdiBookmarkMusicOutline, mdiBookOpenVariant, mdiClipboardPlusOutline, mdiDotsHorizontal, @@ -21,10 +22,12 @@ 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 { 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 { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD } from '../constants'; +import { GetCopyrights } from '../types/spotifyplus/copyright'; +import { GetResumeInfo } from '../types/spotifyplus/resume-point'; +import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; import { IAudiobookSimplified, GetAudiobookNarrators, GetAudiobookAuthors } from '../types/spotifyplus/audiobook-simplified'; import { IChapterPageSimplified } from '../types/spotifyplus/chapter-page-simplified'; @@ -32,6 +35,7 @@ import { IChapterPageSimplified } from '../types/spotifyplus/chapter-page-simpli * Audiobook actions. */ enum Actions { + AudiobookCopyPresetToClipboard = "AudiobookCopyPresetToClipboard", AudiobookCopyUriToClipboard = "AudiobookCopyUriToClipboard", AudiobookFavoriteAdd = "AudiobookFavoriteAdd", AudiobookFavoriteRemove = "AudiobookFavoriteRemove", @@ -127,6 +131,10 @@ class AudiobookActions extends FavActionsBase {
Copy Audiobook URI to Clipboard
+ this.onClickAction(Actions.AudiobookCopyPresetToClipboard)}> + +
Copy Audiobook Preset Info to Clipboard
+
`; @@ -137,6 +145,7 @@ class AudiobookActions extends FavActionsBase { return html`
${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""}
@@ -254,7 +263,13 @@ class AudiobookActions extends FavActionsBase { try { // process actions that don't require a progress indicator. - if (action == Actions.AudiobookCopyUriToClipboard) { + if (action == Actions.AudiobookCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.mediaItem, GetAudiobookAuthors(this.mediaItem, ", "))); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + + } else if (action == Actions.AudiobookCopyUriToClipboard) { copyTextToClipboard(this.mediaItem.uri || ""); return true; diff --git a/src/components/device-actions.ts b/src/components/device-actions.ts index f6b5f05..05c89c7 100644 --- a/src/components/device-actions.ts +++ b/src/components/device-actions.ts @@ -1,23 +1,31 @@ // lovelace card imports. -import { css, html, LitElement, PropertyValues, TemplateResult } from 'lit'; -import { property, state } from 'lit/decorators.js'; +import { css, html, TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; // our imports. import { sharedStylesGrid } from '../styles/shared-styles-grid.js'; import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.js'; -import { Store } from '../model/store'; +import { FavActionsBase } from './fav-actions-base'; +import { Section } from '../types/section'; +import { copyToClipboard } from '../utils/utils'; import { ISpotifyConnectDevice } from '../types/spotifyplus/spotify-connect-device'; -import { copyToClipboard } from '../utils/utils.js'; -class DeviceActions extends LitElement { +class DeviceActions extends FavActionsBase { // public state properties. - @property({ attribute: false }) store!: Store; @property({ attribute: false }) mediaItem!: ISpotifyConnectDevice; - // private state properties. - @state() private _alertError?: string; + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.DEVICES); + + } /** @@ -30,7 +38,8 @@ class DeviceActions extends LitElement { // render html. return html`
- ${this._alertError ? html`${this._alertError}` : ""} + ${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""}
@@ -125,27 +134,6 @@ class DeviceActions extends LitElement { ]; } - - /** - * Called when the element has rendered for the first time. Called once in the - * lifetime of an element. Useful for one-time setup work that requires access to - * the DOM. - */ - protected firstUpdated(changedProperties: PropertyValues): void { - - // invoke base class method. - super.firstUpdated(changedProperties); - - } - - - /** - * Clears the error alert text. - */ - private _alertErrorClear() { - this._alertError = undefined; - } - } diff --git a/src/components/episode-actions.ts b/src/components/episode-actions.ts index df1ce78..93c337f 100644 --- a/src/components/episode-actions.ts +++ b/src/components/episode-actions.ts @@ -3,6 +3,7 @@ import { css, html, TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; import copyTextToClipboard from 'copy-text-to-clipboard'; import { + mdiBookmarkMusicOutline, mdiClipboardPlusOutline, mdiDotsHorizontal, mdiHeart, @@ -22,6 +23,8 @@ 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 { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD } from '../constants'; +import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; import { IEpisode, isEpisodeObject } from '../types/spotifyplus/episode'; import { IEpisodeSimplified } from '../types/spotifyplus/episode-simplified'; @@ -29,11 +32,13 @@ import { IEpisodeSimplified } from '../types/spotifyplus/episode-simplified'; * Episode actions. */ enum Actions { + EpisodeCopyPresetToClipboard = "EpisodeCopyPresetToClipboard", EpisodeCopyUriToClipboard = "EpisodeCopyUriToClipboard", EpisodeFavoriteAdd = "EpisodeFavoriteAdd", EpisodeFavoriteRemove = "EpisodeFavoriteRemove", EpisodeFavoriteUpdate = "EpisodeFavoriteUpdate", EpisodeUpdate = "EpisodeUpdate", + ShowCopyPresetToClipboard = "ShowCopyPresetToClipboard", ShowCopyUriToClipboard = "ShowCopyUriToClipboard", ShowFavoriteAdd = "ShowFavoriteAdd", ShowFavoriteRemove = "ShowFavoriteRemove", @@ -149,7 +154,7 @@ class EpisodeActions extends FavActionsBase { this.onClickAction(Actions.ShowSearchEpisodes)} hide=${this.hideSearchType(SearchMediaTypes.EPISODES)}> - +
Search for Show Episodes
@@ -157,6 +162,10 @@ class EpisodeActions extends FavActionsBase {
Copy Show URI to Clipboard
+ this.onClickAction(Actions.ShowCopyPresetToClipboard)}> + +
Copy Show Preset Info to Clipboard
+
`; @@ -170,6 +179,10 @@ class EpisodeActions extends FavActionsBase {
Copy Episode URI to Clipboard
+ this.onClickAction(Actions.EpisodeCopyPresetToClipboard)}> + +
Copy Episode Preset Info to Clipboard
+
`; @@ -178,6 +191,7 @@ class EpisodeActions extends FavActionsBase { return html`
${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""}
@@ -276,11 +290,23 @@ class EpisodeActions extends FavActionsBase { try { // process actions that don't require a progress indicator. - if (action == Actions.EpisodeCopyUriToClipboard) { + if (action == Actions.EpisodeCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.episode, this.episode?.show.name)); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + + } else if (action == Actions.EpisodeCopyUriToClipboard) { copyTextToClipboard(this.episode?.uri || ""); return true; + } else if (action == Actions.ShowCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.episode?.show, "Podcast")); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.ShowCopyUriToClipboard) { copyTextToClipboard(this.episode?.show.uri || ""); @@ -288,7 +314,7 @@ class EpisodeActions extends FavActionsBase { } else if (action == Actions.ShowSearchEpisodes) { - this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.EPISODES, this.episode?.show.name)); + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.SHOW_EPISODES, this.episode?.show.name, this.episode?.show.name, this.episode?.show.uri)); return true; } diff --git a/src/components/fav-actions-base.ts b/src/components/fav-actions-base.ts index 30d2e84..cdc5e80 100644 --- a/src/components/fav-actions-base.ts +++ b/src/components/fav-actions-base.ts @@ -26,6 +26,7 @@ export class FavActionsBase extends LitElement { // private state properties. @state() protected alertError?: string; + @state() protected alertInfo?: string; /** MediaPlayer instance created from the configuration entity id. */ protected player!: MediaPlayer; @@ -128,6 +129,7 @@ export class FavActionsBase extends LitElement { */ protected alertClear() { this.alertError = undefined; + this.alertInfo = undefined; } @@ -144,6 +146,24 @@ export class FavActionsBase extends LitElement { */ protected alertErrorSet(message: string): void { this.alertError = message; + this.alertInfo = undefined; + } + + + /** + * Clears the info alert text. + */ + protected alertInfoClear() { + this.alertInfo = undefined; + } + + + /** + * Sets the alert info message, and clears the informational alert message. + */ + protected alertInfoSet(message: string): void { + this.alertInfo = message; + this.alertError = undefined; } @@ -311,15 +331,17 @@ export class FavActionsBase extends LitElement { return false; } - // if player reference not set then we are done. - if (!player) { + // if player reference not set then we are done; + // this does not need to be checked for DEVICE section. + if ((!player) && (this.section != Section.DEVICES)) { this.isUpdateInProgress = false; this.alertErrorSet("Player reference not set in updateActions"); return false; } - // if no media item uri, then don't bother. - if (!this.mediaItem.uri) { + // if no media item uri, then don't bother; + // this does not need to be checked for DEVICE section. + if ((!this.mediaItem.uri) && (this.section != Section.DEVICES)) { this.isUpdateInProgress = false; this.alertErrorSet("MediaItem not set in updateActions"); return false; diff --git a/src/components/footer.ts b/src/components/footer.ts index 15d8cec..2251250 100644 --- a/src/components/footer.ts +++ b/src/components/footer.ts @@ -6,6 +6,7 @@ import { mdiAlbum, mdiBookmarkMusicOutline, mdiBookOpenVariant, + mdiDramaMasks, mdiHistory, mdiMicrophone, mdiMusic, @@ -65,6 +66,13 @@ export class Footer extends LitElement { selected=${this.getSectionSelected(Section.RECENTS)} hide=${this.getSectionEnabled(Section.RECENTS)} > + this.onSectionClick(Section.CATEGORYS)} + selected=${this.getSectionSelected(Section.CATEGORYS)} + hide=${this.getSectionEnabled(Section.CATEGORYS)} + > - ${buildMediaBrowserItems(this.items || [], this.config, this.mediaType, this.store).map( + ${buildMediaBrowserItems(this.items || [], this.config, this.mediaItemType, this.searchMediaType, this.store).map( (item, index) => html` - ${styleMediaBrowserItemBackgroundImage(item.mbi_item.image_url, index, this.mediaType)} + ${styleMediaBrowserItemBackgroundImage(item.mbi_item.image_url, index, this.mediaItemType)} ${(() => { if (this.isTouchDevice) { return (html` @@ -101,7 +101,7 @@ export class MediaBrowserIcons extends MediaBrowserBase { .thumbnail { width: 100%; padding-bottom: 100%; - margin: 0 6%; + /* margin: 0.6%; */ background-size: 100%; background-repeat: no-repeat; background-position: center; diff --git a/src/components/media-browser-list.ts b/src/components/media-browser-list.ts index 58aec90..6297525 100644 --- a/src/components/media-browser-list.ts +++ b/src/components/media-browser-list.ts @@ -50,9 +50,9 @@ export class MediaBrowserList extends MediaBrowserBase { // render html. return html` - ${buildMediaBrowserItems(this.items || [], this.config, this.mediaType, this.store).map((item, index) => { + ${buildMediaBrowserItems(this.items || [], this.config, this.mediaItemType, this.searchMediaType, this.store).map((item, index) => { return html` - ${styleMediaBrowserItemBackgroundImage(item.mbi_item.image_url, index, this.mediaType)} + ${styleMediaBrowserItemBackgroundImage(item.mbi_item.image_url, index, this.mediaItemType)} ${(() => { if (this.isTouchDevice) { return (html` diff --git a/src/components/player-body-audiobook.ts b/src/components/player-body-audiobook.ts index ad68d17..971437b 100644 --- a/src/components/player-body-audiobook.ts +++ b/src/components/player-body-audiobook.ts @@ -4,6 +4,7 @@ import { state } from 'lit/decorators.js'; import copyTextToClipboard from 'copy-text-to-clipboard'; import { mdiAccountDetailsOutline, + mdiBookmarkMusicOutline, mdiBookOpenVariant, mdiClipboardPlusOutline, mdiDotsHorizontal, @@ -23,23 +24,28 @@ 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 { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD } from '../constants'; import { GetAudiobookAuthors, GetAudiobookNarrators } from '../types/spotifyplus/audiobook-simplified'; +import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; import { IChapter } from '../types/spotifyplus/chapter'; /** * Audiobook actions. */ enum Actions { + AudiobookCopyPresetToClipboard = "AudiobookCopyPresetToClipboard", AudiobookCopyUriToClipboard = "AudiobookCopyUriToClipboard", AudiobookFavoriteAdd = "AudiobookFavoriteAdd", AudiobookFavoriteRemove = "AudiobookFavoriteRemove", AudiobookFavoriteUpdate = "AudiobookFavoriteUpdate", + AudiobookSearchAuthor = "AudiobookSearchAuthor", + AudiobookSearchNarrator = "AudiobookSearchNarrator", + ChapterCopyPresetToClipboard = "ChapterCopyPresetToClipboard", + ChapterCopyUriToClipboard = "ChapterCopyUriToClipboard", ChapterFavoriteAdd = "ChapterFavoriteAdd", ChapterFavoriteRemove = "ChapterFavoriteRemove", ChapterFavoriteUpdate = "ChapterFavoriteUpdate", GetPlayingItem = "GetPlayingItem", - AudiobookSearchAuthor = "AudiobookSearchAuthor", - AudiobookSearchNarrator = "AudiobookSearchNarrator", } @@ -148,6 +154,27 @@ class PlayerBodyAudiobook extends PlayerBodyBase {
Copy Audiobook URI to Clipboard
+ this.onClickAction(Actions.AudiobookCopyPresetToClipboard)}> + +
Copy Audiobook Preset Info to Clipboard
+
+ + `; + + // define dropdown menu actions - audiobook. + const actionsChapterHtml = html` + + + + + this.onClickAction(Actions.ChapterCopyUriToClipboard)}> + +
Copy Chapter URI to Clipboard
+
+ this.onClickAction(Actions.ChapterCopyPresetToClipboard)}> + +
Copy Chapter Preset Info to Clipboard
+
`; @@ -166,6 +193,9 @@ class PlayerBodyAudiobook extends PlayerBodyBase { ${iconChapter} ${this.chapter?.name} ${(this.isChapterFavorite ? actionChapterFavoriteRemove : actionChapterFavoriteAdd)} + + ${actionsChapterHtml} +
@@ -218,6 +248,7 @@ class PlayerBodyAudiobook extends PlayerBodyBase {
${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""} ${(() => { if (this.player.attributes.sp_item_type == 'audiobook') { return (html`${actionEpisodeSummary}`) @@ -272,15 +303,16 @@ class PlayerBodyAudiobook extends PlayerBodyBase { */ protected override async onClickAction(action: Actions): Promise { - //// if card is being edited, then don't bother. - //if (this.isCardInEditPreview) { - // return true; - //} - try { // process actions that don't require a progress indicator. - if (action == Actions.AudiobookCopyUriToClipboard) { + if (action == Actions.AudiobookCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.chapter?.audiobook, GetAudiobookAuthors(this.chapter?.audiobook, ", "))); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + + } else if (action == Actions.AudiobookCopyUriToClipboard) { copyTextToClipboard(this.chapter?.audiobook.uri || ""); return true; @@ -295,6 +327,17 @@ class PlayerBodyAudiobook extends PlayerBodyBase { this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.AUDIOBOOKS, GetAudiobookNarrators(this.chapter?.audiobook, " "))); return true; + } else if (action == Actions.ChapterCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.chapter, this.chapter?.audiobook.name)); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + + } else if (action == Actions.ChapterCopyUriToClipboard) { + + copyTextToClipboard(this.chapter?.uri || ""); + return true; + } // show progress indicator. diff --git a/src/components/player-body-base.ts b/src/components/player-body-base.ts index 4f2e8d3..aa33055 100644 --- a/src/components/player-body-base.ts +++ b/src/components/player-body-base.ts @@ -184,7 +184,7 @@ export class PlayerBodyBase extends LitElement { if (changedPropKeys.includes('mediaContentId')) { if (debuglog.enabled) { - debuglog("%c update - player content changed:\n- NEW CONTENT ID = %s\n- isCardInEditPreview = %s", + debuglog("%cupdate - player content changed:\n- NEW CONTENT ID = %s\n- isCardInEditPreview = %s", "color: gold;", JSON.stringify(this.player.attributes.media_content_id), JSON.stringify(isCardInEditPreview(this.store.card)), @@ -228,6 +228,23 @@ export class PlayerBodyBase extends LitElement { } + /** + * Clears the info alert text. + */ + protected alertInfoClear() { + this.alertInfo = undefined; + } + + + /** + * Sets the alert info message, and clears the informational alert message. + */ + protected alertInfoSet(message: string): void { + this.alertInfo = message; + this.alertError = undefined; + } + + /** * Hide visual progress indicator. */ diff --git a/src/components/player-body-queue.ts b/src/components/player-body-queue.ts index 733591f..2984982 100644 --- a/src/components/player-body-queue.ts +++ b/src/components/player-body-queue.ts @@ -114,6 +114,7 @@ export class PlayerBodyQueue extends PlayerBodyBase {
${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""}
Player Queue Info - ${queueInfoTitle}
diff --git a/src/components/player-body-show.ts b/src/components/player-body-show.ts index 583b544..39161f8 100644 --- a/src/components/player-body-show.ts +++ b/src/components/player-body-show.ts @@ -3,6 +3,7 @@ import { css, html, TemplateResult } from 'lit'; import { state } from 'lit/decorators.js'; import copyTextToClipboard from 'copy-text-to-clipboard'; import { + mdiBookmarkMusicOutline, mdiClipboardPlusOutline, mdiDotsHorizontal, mdiHeart, @@ -22,17 +23,21 @@ 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 { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD } from '../constants'; +import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; import { IEpisode } from '../types/spotifyplus/episode'; /** * Show actions. */ enum Actions { + EpisodeCopyPresetToClipboard = "EpisodeCopyPresetToClipboard", EpisodeCopyUriToClipboard = "EpisodeCopyUriToClipboard", EpisodeFavoriteAdd = "EpisodeFavoriteAdd", EpisodeFavoriteRemove = "EpisodeFavoriteRemove", EpisodeFavoriteUpdate = "EpisodeFavoriteUpdate", GetPlayingItem = "GetPlayingItem", + ShowCopyPresetToClipboard = "ShowCopyPresetToClipboard", ShowCopyUriToClipboard = "ShowCopyUriToClipboard", ShowFavoriteAdd = "ShowFavoriteAdd", ShowFavoriteRemove = "ShowFavoriteRemove", @@ -134,7 +139,7 @@ class PlayerBodyShow extends PlayerBodyBase { this.onClickAction(Actions.ShowSearchEpisodes)} hide=${this.hideSearchType(SearchMediaTypes.EPISODES)}> - +
Search for Show Episodes
@@ -142,6 +147,10 @@ class PlayerBodyShow extends PlayerBodyBase {
Copy Show URI to Clipboard
+ this.onClickAction(Actions.ShowCopyPresetToClipboard)}> + +
Copy Show Preset Info to Clipboard
+
`; @@ -155,6 +164,10 @@ class PlayerBodyShow extends PlayerBodyBase {
Copy Episode URI to Clipboard
+ this.onClickAction(Actions.EpisodeCopyPresetToClipboard)}> + +
Copy Episode Preset Info to Clipboard
+
`; @@ -216,6 +229,7 @@ class PlayerBodyShow extends PlayerBodyBase {
${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""} ${(() => { if (this.player.attributes.sp_item_type == 'podcast') { return (html`${actionEpisodeSummary}`) @@ -256,19 +270,26 @@ class PlayerBodyShow extends PlayerBodyBase { */ protected override async onClickAction(action: Actions): Promise { - //// if card is being edited, then don't bother. - //if (this.isCardInEditPreview) { - // return true; - //} - try { // process actions that don't require a progress indicator. - if (action == Actions.EpisodeCopyUriToClipboard) { + if (action == Actions.EpisodeCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.episode, this.episode?.show.name)); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + + } else if (action == Actions.EpisodeCopyUriToClipboard) { copyTextToClipboard(this.episode?.uri || ""); return true; + } else if (action == Actions.ShowCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.episode?.show, "Podcast")); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.ShowCopyUriToClipboard) { copyTextToClipboard(this.episode?.show.uri || ""); @@ -276,7 +297,7 @@ class PlayerBodyShow extends PlayerBodyBase { } else if (action == Actions.ShowSearchEpisodes) { - this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.EPISODES, this.episode?.show.name)); + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.SHOW_EPISODES, this.episode?.show.name, this.episode?.show.name, this.episode?.show.uri)); return true; } diff --git a/src/components/player-body-track.ts b/src/components/player-body-track.ts index 894bd1a..8045b39 100644 --- a/src/components/player-body-track.ts +++ b/src/components/player-body-track.ts @@ -5,6 +5,7 @@ import copyTextToClipboard from 'copy-text-to-clipboard'; import { mdiAccountMusic, mdiAlbum, + mdiBookmarkMusicOutline, mdiClipboardPlusOutline, mdiDotsHorizontal, mdiHeart, @@ -25,7 +26,8 @@ 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 { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD, RADIO_SEARCH_KEY } from '../constants.js'; +import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset.js'; import { ITrack } from '../types/spotifyplus/track'; /** @@ -33,6 +35,7 @@ import { ITrack } from '../types/spotifyplus/track'; */ enum Actions { GetPlayingItem = "GetPlayingItem", + AlbumCopyPresetToClipboard = "AlbumCopyPresetToClipboard", AlbumCopyUriToClipboard = "AlbumCopyUriToClipboard", AlbumFavoriteAdd = "AlbumFavoriteAdd", AlbumFavoriteRemove = "AlbumFavoriteRemove", @@ -45,6 +48,12 @@ enum Actions { ArtistSearchPlaylists = "ArtistSearchPlaylists", ArtistSearchRadio = "ArtistSearchRadio", ArtistSearchTracks = "ArtistSearchTracks", + ArtistShowAlbums = "ArtistShowAlbums", + ArtistShowAlbumsAppearsOn = "ArtistShowAlbumsAppearsOn", + ArtistShowAlbumsCompilation = "ArtistShowAlbumsCompilation", + ArtistShowAlbumsSingle = "ArtistShowAlbumsSingle", + ArtistShowRelatedArtists = "ArtistShowRelatedArtists", + ArtistShowTopTracks = "ArtistShowTopTracks", TrackCopyUriToClipboard = "TrackCopyUriToClipboard", TrackFavoriteAdd = "TrackFavoriteAdd", TrackFavoriteRemove = "TrackFavoriteRemove", @@ -211,6 +220,10 @@ class PlayerBodyTrack extends PlayerBodyBase {
Copy Album URI to Clipboard
+ this.onClickAction(Actions.AlbumCopyPresetToClipboard)}> + +
Copy Album Preset Info to Clipboard
+
`; @@ -233,6 +246,31 @@ class PlayerBodyTrack extends PlayerBodyBase {
Search for Artist Radio
+ this.onClickAction(Actions.ArtistShowTopTracks)} hide=${this.hideSearchType(SearchMediaTypes.TRACKS)}> + +
Show Artist Top Tracks
+
+ this.onClickAction(Actions.ArtistShowAlbums)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Show Artist Albums
+
+ this.onClickAction(Actions.ArtistShowAlbumsCompilation)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Show Artist Albums Compilations
+
+ this.onClickAction(Actions.ArtistShowAlbumsSingle)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Show Artist Albums Singles
+
+ this.onClickAction(Actions.ArtistShowAlbumsAppearsOn)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Show Artist Albums AppearsOn
+
+ this.onClickAction(Actions.ArtistShowRelatedArtists)} hide=${this.hideSearchType(SearchMediaTypes.ARTISTS)}> + +
Show Related Artists
+
+ this.onClickAction(Actions.ArtistCopyUriToClipboard)}>
Copy Artist URI to Clipboard
@@ -315,6 +353,7 @@ class PlayerBodyTrack extends PlayerBodyBase {
${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""} ${(() => { if (this.player.attributes.sp_item_type == 'track') { return (html`${actionTrackSummary}`) @@ -354,15 +393,16 @@ class PlayerBodyTrack extends PlayerBodyBase { */ protected override async onClickAction(action: Actions): Promise { - //// if card is being edited, then don't bother. - //if (this.isCardInEditPreview) { - // return true; - //} - try { // process actions that don't require a progress indicator. - if (action == Actions.AlbumCopyUriToClipboard) { + if (action == Actions.AlbumCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.track?.album, this.track?.album.artists[0].name)); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + + } else if (action == Actions.AlbumCopyUriToClipboard) { copyTextToClipboard(this.track?.album.uri || ""); return true; @@ -392,6 +432,36 @@ class PlayerBodyTrack extends PlayerBodyBase { this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.TRACKS, this.track?.artists[0].name)); return true; + } else if (action == Actions.ArtistShowAlbums) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_ALBUMS, this.track?.artists[0].name, this.track?.artists[0].name, this.track?.artists[0].uri)); + return true; + + } else if (action == Actions.ArtistShowAlbumsAppearsOn) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_ALBUMS_APPEARSON, this.track?.artists[0].name, this.track?.artists[0].name, this.track?.artists[0].uri)); + return true; + + } else if (action == Actions.ArtistShowAlbumsCompilation) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_ALBUMS_COMPILATION, this.track?.artists[0].name, this.track?.artists[0].name, this.track?.artists[0].uri)); + return true; + + } else if (action == Actions.ArtistShowAlbumsSingle) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_ALBUMS_SINGLE, this.track?.artists[0].name, this.track?.artists[0].name, this.track?.artists[0].uri)); + return true; + + } else if (action == Actions.ArtistShowRelatedArtists) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_RELATED_ARTISTS, this.track?.artists[0].name, this.track?.artists[0].name, this.track?.artists[0].uri)); + return true; + + } else if (action == Actions.ArtistShowTopTracks) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_TOP_TRACKS, this.track?.artists[0].name, this.track?.artists[0].name, this.track?.artists[0].uri)); + return true; + } else if (action == Actions.TrackCopyUriToClipboard) { copyTextToClipboard(this.track?.uri || ""); diff --git a/src/components/player-controls.ts b/src/components/player-controls.ts index 8487cc2..9e65891 100644 --- a/src/components/player-controls.ts +++ b/src/components/player-controls.ts @@ -243,7 +243,7 @@ class PlayerControls extends LitElement { //} //if (debuglog.enabled) { - // debuglog("%c update - changed properties: %s", + // debuglog("%cupdate - changed properties: %s", // "color: gold;", // JSON.stringify(changedPropKeys), // ); diff --git a/src/components/playlist-actions.ts b/src/components/playlist-actions.ts index 341d6b9..4064a52 100644 --- a/src/components/playlist-actions.ts +++ b/src/components/playlist-actions.ts @@ -1,9 +1,11 @@ // lovelace card imports. import { css, html, TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; +//import { fireEvent } from 'custom-card-helpers'; import copyTextToClipboard from 'copy-text-to-clipboard'; import { mdiBackupRestore, + mdiBookmarkMusicOutline, mdiClipboardPlusOutline, mdiDotsHorizontal, mdiHeart, @@ -20,15 +22,20 @@ 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'; +import { GetPlaylistPagePlaylistTracks } from '../types/spotifyplus/playlist-page'; +import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; +import { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD } from '../constants'; +import { IPlaylistSimplified } from '../types/spotifyplus/playlist-simplified'; +import { IPlaylistTrack } from '../types/spotifyplus/playlist-track'; + +//import { getLovelace, parseLovelaceCardPath } from '../utils/config-util'; /** * Playlist actions. */ enum Actions { + PlaylistCopyPresetToClipboard = "PlaylistCopyPresetToClipboard", PlaylistCopyUriToClipboard = "PlaylistCopyUriToClipboard", PlaylistDelete = "PlaylistDelete", PlaylistFavoriteAdd = "PlaylistFavoriteAdd", @@ -124,6 +131,10 @@ class PlaylistActions extends FavActionsBase {
Copy Playlist URI to Clipboard
+ this.onClickAction(Actions.PlaylistCopyPresetToClipboard)}> + +
Copy Playlist Preset Info to Clipboard
+
`; @@ -131,6 +142,7 @@ class PlaylistActions extends FavActionsBase { return html`
${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""}
@@ -267,14 +279,111 @@ class PlaylistActions extends FavActionsBase { // process actions that don't require a progress indicator. if (action == Actions.PlaylistCopyUriToClipboard) { - copyTextToClipboard(this.mediaItem.uri); - return true; + copyTextToClipboard(this.mediaItem.uri); + return true; } else if (action == Actions.PlaylistRecoverWebUI) { openWindowNewTab("https://www.spotify.com/us/account/recover-playlists/"); return true; + } else if (action == Actions.PlaylistCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.mediaItem)); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + + // the following was my attempt to automatically add the new preset to the + // configuration. it partially worked, in that it would add the preset to + // the configuration in memory, the preset would be displayed in the preset + // browser, but the update was not applied to the lovelace configuration that + // is stored on disk in the `\config\.storage\lovelace.xxxxx` location. + + //// create user preset object. + //const preset: IUserPreset = { + // name: this.mediaItem.name, + // image_url: this.mediaItem.image_url || "", + // subtitle: this.mediaItem.type, + // type: this.mediaItem.type, + // uri: this.mediaItem.uri, + //}; + + //const CRLF = "\n"; + //let presetText = ""; + //presetText += " - name: " + preset.name + CRLF; + //presetText += " subtitle: " + preset.subtitle + CRLF; + //presetText += " image_url: " + preset.image_url + CRLF; + //presetText += " uri: " + preset.uri + CRLF; + //presetText += " type: " + preset.type + CRLF; + + //// add to configuration; insert new item at the beginning. + //this.store.config.userPresets?.unshift(preset); + + //// update configuration (in memory). + //// note that this will ONLY update the configuration stored in memory; it + //// does not apply the updates to the lovelace raw config stored on disk in + //// the `\config\.storage\lovelace.xxxxx` location! + //fireEvent(this, 'config-changed', { config: this.store.config }); + + //// prepare to update the lovelace configuration (on disk). + //const lovelace = getLovelace(); + //if (lovelace) { + + // console.log("%conClickAction - lovelace data:\n- editMode = %s\n- mode = %s\n- locale = %s\n- urlPath = %s", + // "color: gold", + // JSON.stringify(lovelace.editMode), + // JSON.stringify(lovelace.mode), + // JSON.stringify(lovelace.locale), + // JSON.stringify(lovelace.urlPath), + // ); + + // console.log("%conClickAction - lovelace.rawConfig:\n%s", + // "color: red", + // JSON.stringify(lovelace.rawConfig, null, 2), + // ); + + // console.log("%conClickAction - lovelace.config:\n%s", + // "color: gold", + // JSON.stringify(lovelace.config, null, 2), + // ); + + // //export const replaceCard = ( + // // config: LovelaceConfig, + // // path: LovelaceCardPath, + // // cardConfig: LovelaceCardConfig + // //): LovelaceConfig => { + + // // const { cardIndex } = parseLovelaceCardPath(path); + // // const containerPath = getLovelaceContainerPath(path); + + // // const cards = findLovelaceItems("cards", config, containerPath); + + // // const newCards = (cards ?? []).map((origConf, ind) => + // // ind === cardIndex ? cardConfig : origConf + // // ); + + // // const newConfig = updateLovelaceItems( + // // "cards", + // // config, + // // containerPath, + // // newCards + // // ); + // // return newConfig; + // //}; + + // //let config: LovelaceRawConfig; + // //await lovelace.saveConfig(config); <- this is the LovelaceRawConfig, not the card config!!! + + //} else { + + // //console.log("%conClickAction - could not get lovelace object!", + // // "color: red", + // //); + + //} + + //return true; + } // show progress indicator. diff --git a/src/components/show-actions.ts b/src/components/show-actions.ts index 28fceeb..647453f 100644 --- a/src/components/show-actions.ts +++ b/src/components/show-actions.ts @@ -3,11 +3,13 @@ import { css, html, TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; import copyTextToClipboard from 'copy-text-to-clipboard'; import { + mdiBookmarkMusicOutline, mdiClipboardPlusOutline, mdiDotsHorizontal, mdiHeart, mdiHeartOutline, mdiPlaylistPlay, + mdiMicrophone, mdiPodcast, } from '@mdi/js'; @@ -22,8 +24,10 @@ 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 { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD } from '../constants'; import { GetCopyrights } from '../types/spotifyplus/copyright'; +import { GetResumeInfo } from '../types/spotifyplus/resume-point'; +import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; import { IShowSimplified } from '../types/spotifyplus/show-simplified'; import { IEpisodePageSimplified } from '../types/spotifyplus/episode-page-simplified'; @@ -31,6 +35,7 @@ import { IEpisodePageSimplified } from '../types/spotifyplus/episode-page-simpli * Show actions. */ enum Actions { + ShowCopyPresetToClipboard = "ShowCopyPresetToClipboard", ShowCopyUriToClipboard = "ShowCopyUriToClipboard", ShowEpisodesUpdate = "ShowEpisodesUpdate", ShowFavoriteAdd = "ShowFavoriteAdd", @@ -113,7 +118,7 @@ class ShowActions extends FavActionsBase { this.onClickAction(Actions.ShowSearchEpisodes)} hide=${this.hideSearchType(SearchMediaTypes.EPISODES)}> - +
Search for Show Episodes
@@ -121,6 +126,10 @@ class ShowActions extends FavActionsBase {
Copy Show URI to Clipboard
+ this.onClickAction(Actions.ShowCopyPresetToClipboard)}> + +
Copy Show Preset Info to Clipboard
+
`; @@ -132,6 +141,7 @@ class ShowActions extends FavActionsBase { return html`
${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""}
@@ -242,14 +252,20 @@ class ShowActions extends FavActionsBase { try { // process actions that don't require a progress indicator. - if (action == Actions.ShowCopyUriToClipboard) { + if (action == Actions.ShowCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.mediaItem, "Podcast")); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + + } else if (action == Actions.ShowCopyUriToClipboard) { copyTextToClipboard(this.mediaItem.uri); return true; } else if (action == Actions.ShowSearchEpisodes) { - this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.EPISODES, this.mediaItem.name)); + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.SHOW_EPISODES, this.mediaItem.name, this.mediaItem.name, this.mediaItem.uri)); return true; } @@ -319,7 +335,7 @@ class ShowActions extends FavActionsBase { const promiseGetShowEpisodes = new Promise((resolve, reject) => { const market = null; - const limit_total = 200; + const limit_total = 20; // call service to retrieve show episodes. this.spotifyPlusService.GetShowEpisodes(player.id, this.mediaItem.id, 0, 0, market, limit_total) diff --git a/src/components/track-actions.ts b/src/components/track-actions.ts index ef7d009..d2d91d4 100644 --- a/src/components/track-actions.ts +++ b/src/components/track-actions.ts @@ -5,6 +5,7 @@ import copyTextToClipboard from 'copy-text-to-clipboard'; import { mdiAccountMusic, mdiAlbum, + mdiBookmarkMusicOutline, mdiClipboardPlusOutline, mdiDotsHorizontal, mdiHeart, @@ -26,13 +27,15 @@ 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 { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD, RADIO_SEARCH_KEY } from '../constants.js'; +import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset.js'; import { ITrack } from '../types/spotifyplus/track'; /** * Track actions. */ enum Actions { + AlbumCopyPresetToClipboard = "AlbumCopyPresetToClipboard", AlbumCopyUriToClipboard = "AlbumCopyUriToClipboard", AlbumFavoriteAdd = "AlbumFavoriteAdd", AlbumFavoriteRemove = "AlbumFavoriteRemove", @@ -45,6 +48,12 @@ enum Actions { ArtistSearchPlaylists = "ArtistSearchPlaylists", ArtistSearchRadio = "ArtistSearchRadio", ArtistSearchTracks = "ArtistSearchTracks", + ArtistShowAlbums = "ArtistShowAlbums", + ArtistShowAlbumsAppearsOn = "ArtistShowAlbumsAppearsOn", + ArtistShowAlbumsCompilation = "ArtistShowAlbumsCompilation", + ArtistShowAlbumsSingle = "ArtistShowAlbumsSingle", + ArtistShowRelatedArtists = "ArtistShowRelatedArtists", + ArtistShowTopTracks = "ArtistShowTopTracks", TrackCopyUriToClipboard = "TrackCopyUriToClipboard", TrackFavoriteAdd = "TrackFavoriteAdd", TrackFavoriteRemove = "TrackFavoriteRemove", @@ -230,6 +239,10 @@ class TrackActions extends FavActionsBase {
Copy Album URI to Clipboard
+ this.onClickAction(Actions.AlbumCopyPresetToClipboard)}> + +
Copy Album Preset Info to Clipboard
+
`; @@ -252,6 +265,31 @@ class TrackActions extends FavActionsBase {
Search for Artist Radio
+ this.onClickAction(Actions.ArtistShowTopTracks)} hide=${this.hideSearchType(SearchMediaTypes.TRACKS)}> + +
Show Artist Top Tracks
+
+ this.onClickAction(Actions.ArtistShowAlbums)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Show Artist Albums
+
+ this.onClickAction(Actions.ArtistShowAlbumsCompilation)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Show Artist Albums Compilations
+
+ this.onClickAction(Actions.ArtistShowAlbumsSingle)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Show Artist Albums Singles
+
+ this.onClickAction(Actions.ArtistShowAlbumsAppearsOn)} hide=${this.hideSearchType(SearchMediaTypes.ALBUMS)}> + +
Show Artist Albums AppearsOn
+
+ this.onClickAction(Actions.ArtistShowRelatedArtists)} hide=${this.hideSearchType(SearchMediaTypes.ARTISTS)}> + +
Show Related Artists
+
+ this.onClickAction(Actions.ArtistCopyUriToClipboard)}>
Copy Artist URI to Clipboard
@@ -264,6 +302,7 @@ class TrackActions extends FavActionsBase { return html`
${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""}
@@ -378,7 +417,13 @@ class TrackActions extends FavActionsBase { try { // process actions that don't require a progress indicator. - if (action == Actions.AlbumCopyUriToClipboard) { + if (action == Actions.AlbumCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.mediaItem.album, this.mediaItem.album.artists[0].name)); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + + } else if (action == Actions.AlbumCopyUriToClipboard) { copyTextToClipboard(this.mediaItem.album.uri); return true; @@ -403,6 +448,36 @@ class TrackActions extends FavActionsBase { this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.mediaItem.artists[0].name + RADIO_SEARCH_KEY)); return true; + } else if (action == Actions.ArtistShowAlbums) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_ALBUMS, this.mediaItem.artists[0].name, this.mediaItem.artists[0].name, this.mediaItem.artists[0].uri)); + return true; + + } else if (action == Actions.ArtistShowAlbumsAppearsOn) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_ALBUMS_APPEARSON, this.mediaItem.artists[0].name, this.mediaItem.artists[0].name, this.mediaItem.artists[0].uri)); + return true; + + } else if (action == Actions.ArtistShowAlbumsCompilation) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_ALBUMS_COMPILATION, this.mediaItem.artists[0].name, this.mediaItem.artists[0].name, this.mediaItem.artists[0].uri)); + return true; + + } else if (action == Actions.ArtistShowAlbumsSingle) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_ALBUMS_SINGLE, this.mediaItem.artists[0].name, this.mediaItem.artists[0].name, this.mediaItem.artists[0].uri)); + return true; + + } else if (action == Actions.ArtistShowRelatedArtists) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_RELATED_ARTISTS, this.mediaItem.artists[0].name, this.mediaItem.artists[0].name, this.mediaItem.artists[0].uri)); + return true; + + } else if (action == Actions.ArtistShowTopTracks) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_TOP_TRACKS, this.mediaItem.artists[0].name, this.mediaItem.artists[0].name, this.mediaItem.artists[0].uri)); + return true; + } else if (action == Actions.ArtistSearchTracks) { this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.TRACKS, this.mediaItem.artists[0].name)); diff --git a/src/components/userpreset-actions.ts b/src/components/userpreset-actions.ts index d6890c3..88e0e95 100644 --- a/src/components/userpreset-actions.ts +++ b/src/components/userpreset-actions.ts @@ -43,6 +43,7 @@ class UserPresetActions extends FavActionsBase { return html`
${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""}
diff --git a/src/constants.ts b/src/constants.ts index d1fb9e0..975a701 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.11'; +export const CARD_VERSION = '1.0.12'; /** SpotifyPlus integration domain identifier. */ export const DOMAIN_SPOTIFYPLUS = 'spotifyplus'; @@ -57,3 +57,5 @@ export const listStyle = css` overflow: hidden; } `; + +export const ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD = "Preset info copied to clipboard; please edit the card configuration (via show code editor) and paste copied text under the \"userPresets:\" key." diff --git a/src/decorators/storage.ts b/src/decorators/storage.ts index 26d632c..6f95cea 100644 --- a/src/decorators/storage.ts +++ b/src/decorators/storage.ts @@ -82,6 +82,7 @@ export class LStorageService { */ public getStorageValue(storageKey: any, defaultValue: any = null): any { const storageData = window.localStorage.getItem(storageKey); + //console.log("%cgetStorageValue - key=%s, data=%s", "color:red", JSON.stringify(storageKey), JSON.stringify(storageData)); return (storageData) ? JSON.parse(storageData) : defaultValue; } diff --git a/src/editor/audiobook-fav-browser-editor.ts b/src/editor/audiobook-fav-browser-editor.ts index 06d3828..98b0d84 100644 --- a/src/editor/audiobook-fav-browser-editor.ts +++ b/src/editor/audiobook-fav-browser-editor.ts @@ -73,7 +73,7 @@ class AudiobookFavBrowserEditor extends BaseEditor {
+ Settings that control the Category section look and feel +
+ + `; + } + + + /** + * Style definitions used by this TemplateResult. + * + * Use the "spc-editor-form" class to apply styling to the elements that are dynamically defined by + * the HA-FORM element. This gives you the ability to generate a more compact look and feel to the + * element, which can save quite a bit of screen real-estate in the process! + * See the static "styles()" function in the "editor.ts" module for more details. + */ + static get styles() { + return css` + + .schema-title { + margin: 0.4rem 0; + text-align: left; + font-size: 1rem; + color: var(--secondary-text-color); + } + + /* control the look and feel of the HA-FORM element. */ + .spc-editor-form { + } + + `; + } + + + /** + * Handles a "value-changed" event. + * This event is raised whenever a form value is changed in the UI. + */ + protected onValueChanged(args: CustomEvent): void { + + // nothing to do here for this media type. + // left the code here for future changes. + if (args) { + } + + } + +} + +customElements.define('spc-category-browser-editor', CategoryBrowserEditor); diff --git a/src/editor/editor-form.ts b/src/editor/editor-form.ts index 731e498..eb1bad9 100644 --- a/src/editor/editor-form.ts +++ b/src/editor/editor-form.ts @@ -162,7 +162,7 @@ class Form extends BaseEditor { if (haFormField?.tagName == "HA-FORMFIELD") { haFormField.setAttribute("style", "min-height: var(--ha-form-style-selector-boolean-min-height, 56px);"); } else { - console.log("%c HA-SELECTOR underlying type was not styled: %s", "color:orange", child.tagName); + console.log("%c HA-SELECTOR underlying type was not styled: %s", "color:red", child.tagName); } } @@ -177,7 +177,7 @@ class Form extends BaseEditor { child.setAttribute("style", "margin-bottom: var(--ha-form-style-integer-margin-bottom, 24px);"); } else { - console.log("%c _styleRenderRootElements (editor-form) - did not style %s element", "color:orange", child.tagName); + console.log("%c _styleRenderRootElements (editor-form) - did not style %s element", "color:red", child.tagName); } } diff --git a/src/editor/editor.ts b/src/editor/editor.ts index e604892..d330d9a 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -10,6 +10,7 @@ import './player-editor'; import './album-fav-browser-editor'; import './artist-fav-browser-editor'; import './audiobook-fav-browser-editor'; +import './category-browser-editor'; import './device-browser-editor'; import './episode-fav-browser-editor'; import './playlist-fav-browser-editor'; @@ -87,7 +88,7 @@ class CardEditor extends BaseEditor { )} - ${[ConfigArea.EPISODE_FAVORITES, ConfigArea.SHOW_FAVORITES, ConfigArea.SEARCH_MEDIA_BROWSER].map( + ${[ConfigArea.EPISODE_FAVORITES, ConfigArea.SHOW_FAVORITES, ConfigArea.CATEGORY_BROWSER, ConfigArea.SEARCH_MEDIA_BROWSER].map( (configArea) => html` html``, ], + [ + ConfigArea.CATEGORY_BROWSER, + () => html``, + ], [ ConfigArea.DEVICE_BROWSER, () => html``, diff --git a/src/editor/general-editor.ts b/src/editor/general-editor.ts index 624f429..0dbc96f 100644 --- a/src/editor/general-editor.ts +++ b/src/editor/general-editor.ts @@ -20,6 +20,7 @@ const CONFIG_SETTINGS_SCHEMA = [ albumfavorites: 'Album Favorites', /* Section.ALBUM_FAVORITES */ artistfavorites: 'Artist Favorites', /* Section.ARTIST_FAVORITES */ audiobookfavorites: 'Audiobook Favorites', /* Section.AUDIOBOOK_FAVORITES */ + categorys: 'Categorys', /* Section.CATEGORYS */ devices: 'Devices', /* Section.DEVICES */ episodefavorites: 'Episode Favorites', /* Section.EPISODE_FAVORITES */ playlistfavorites: 'Playlist Favorites', /* Section.PLAYLIST_FAVORITES */ diff --git a/src/editor/show-fav-browser-editor.ts b/src/editor/show-fav-browser-editor.ts index ea850dc..b8efca3 100644 --- a/src/editor/show-fav-browser-editor.ts +++ b/src/editor/show-fav-browser-editor.ts @@ -73,7 +73,7 @@ class ShowFavBrowserEditor extends BaseEditor {
| undefined; + + /** Array of category playlists to display in the media list. */ + private categoryPlaylists!: Array | undefined; + + /** Date and time (in epoch format) of when the media list was last updated. */ + private categoryPlaylistsLastUpdatedOn!: number; + + /** Filter criteria used for the category list. */ + private categoryListFilter!: string | undefined; + + /** Saved scroll position of the category list. */ + private categoryListScrollTopSaved!: number | undefined; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.CATEGORYS); + this.filterCriteriaPlaceholder = "filter by category name"; + + } + + + /** + * 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 { + + // invoke base class method. + super.render(); + + // format title and sub-title details based on list that is currently displayed. + let title: string | undefined = ""; + let subtitle: string | undefined = ""; + if (this.isCategoryVisible) { + title = formatTitleInfo(this.config.categoryBrowserTitle, this.config, this.player, this.categoryPlaylistsLastUpdatedOn, this.categoryPlaylists); + subtitle = formatTitleInfo(this.config.categoryBrowserSubTitle, this.config, this.player, this.categoryPlaylistsLastUpdatedOn, this.categoryPlaylists); + } else { + title = formatTitleInfo(this.config.categoryBrowserTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + subtitle = formatTitleInfo(this.config.categoryBrowserSubTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + } + + // render html. + return html` +
+ ${title ? html`
${title}
` : html``} + ${subtitle ? html`
${subtitle}
` : html``} +
+ ${(this.isActionsVisible || this.isCategoryVisible || false) ?html`${this.btnHideActionsHtml}` : html``} + ${this.filterCriteriaHtml}${this.refreshMediaListHtml} +
+
+ ${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""} + ${(() => { + if (this.isActionsVisible) { + // if actions are visbile, then render the actions display. + return html``; + } else if (this.isCategoryVisible) { + // if category is visible, then render the playlists for the category. + const filterName = (this.filterCriteria || "").toLocaleLowerCase(); + if (this.config.categoryBrowserItemsPerRow === 1) { + return ( + html` item.name.toLocaleLowerCase().indexOf(filterName) !== -1)} + .store=${this.store} + @item-selected=${this.onItemSelected} + @item-selected-with-hold=${this.onItemSelectedWithHold} + >` + ) + } else { + return ( + html` item.name.toLocaleLowerCase().indexOf(filterName) !== -1)} + .store=${this.store} + @item-selected=${this.onItemSelected} + @item-selected-with-hold=${this.onItemSelectedWithHold} + >` + ) + } + } else { + // if category is not visbile, then render the category list. + const filterName = (this.filterCriteria || "").toLocaleLowerCase(); + if (this.config.categoryBrowserItemsPerRow === 1) { + return ( + html` item.name.toLocaleLowerCase().indexOf(filterName) !== -1)} + .store=${this.store} + @item-selected=${this.onItemSelected} + @item-selected-with-hold=${this.onItemSelectedWithHold} + >` + ) + } else { + return ( + html` item.name.toLocaleLowerCase().indexOf(filterName) !== -1)} + .store=${this.store} + @item-selected=${this.onItemSelected} + @item-selected-with-hold=${this.onItemSelectedWithHold} + >` + ) + } + } + })()} +
+
+ `; + } + + + /** + * Handles the `click` event fired when the hide or refresh actions icon is clicked. + * + * @param evArgs Event arguments that contain the icon that was clicked on. + */ + protected override onFilterActionsClick(ev: MouseEvent) { + + // get action to perform. + const action = (ev.currentTarget! as HTMLElement).getAttribute("action")!; + + // was hide actions requested? + if (action === "hideactions") { + + // if detail actions are visible then let the base class handle it; + if (this.isActionsVisible) { + + // let base class handle the event. + super.onFilterActionsClick(ev); + + } else if (this.isCategoryVisible) { + + // if category playlist items are visible, then reset category id and + // display the category list. + this.categoryId = ""; + this.isCategoryVisible = false; + this.filterCriteria = this.categoryListFilter; + this.scrollTopSaved = this.categoryListScrollTopSaved; + + // set a timeout to re-apply media list items scroll position, as some of the shadowRoot + // elements may not have completed updating when the re-render occured. + setTimeout(() => { + this.requestUpdate(); + }, 50); + } + + } else { + + // let base class handle the event. + super.onFilterActionsClick(ev); + + } + + } + + + /** + * Handles the `item-selected` event fired when a media browser item is clicked. + * + * @param evArgs Event arguments that contain the media item that was clicked on. + */ + protected override onItemSelected(evArgs: CustomEvent) { + + if (debuglog.enabled) { + debuglog("onItemSelected - media item selected:\n%s", + JSON.stringify(evArgs.detail, null, 2), + ); + } + + // event could contain a category item, or a category playlist item. + const eventType = evArgs.detail.type; + + // was a category clicked? + if (eventType == "category") { + + // main category was selected; event argument is an ICategory item. + const args = evArgs.detail as ICategory; + + // save scroll position. + this.scrollTopSaved = this.mediaBrowserContentElement.scrollTop; + + // save category id and display playlists for the selected category. + this.categoryId = args.id; + this.isCategoryVisible = true; + this.categoryListFilter = this.filterCriteria; + this.categoryListScrollTopSaved = this.scrollTopSaved; + this.filterCriteria = ""; + this.categoryPlaylists = undefined; + this.requestUpdate(); + this.updateMediaList(this.player); + + } else { + + // category playlist was selected; event argument is an IPlayListSimplified item. + // just call base class method to play the media item (it's a playlist). + super.onItemSelected(evArgs); + + } + + } + + + /** + * Handles the `item-selected-with-hold` event fired when a media browser item is clicked and held. + * + * @param evArgs Event arguments that contain the media item that was clicked on. + */ + protected override onItemSelectedWithHold(evArgs: CustomEvent) { + + if (debuglog.enabled) { + debuglog("onItemSelectedWithHold - media item selected:\n%s", + JSON.stringify(evArgs.detail, null, 2), + ); + } + + // event could contain a category item, or a playlist item. + const eventType = evArgs.detail.type; + + // was a category clicked? + if (eventType == "category") { + + // main category was selected; event argument is an ICategory item. + // no details can be displayed for the category, so just select it. + this.onItemSelected(evArgs); + + } else { + + // otherwise, just invoke the base class method to handle the event. + // this will display the tracks in the category playlist. + super.onItemSelectedWithHold(evArgs); + + } + + } + + + /** + * Updates the mediaList display. + */ + protected override updateMediaList(player: MediaPlayer): boolean { + + // invoke base class method; if it returns false, then we should not update the media list. + if (!super.updateMediaList(player)) { + return false; + } + + try { + + // we use the `Promise.allSettled` approach here like we do with actions, so + // that we can easily add promises if more data gathering is needed in the future. + const promiseRequests = new Array>(); + + if (this.isCategoryVisible) { + + // create promise - get playlists for category. + const promiseGetCategorysList = new Promise((resolve, reject) => { + + // set service parameters. + const country = null; + const limitTotal = this.config.searchMediaBrowserSearchLimit || 50; + const sortResult = this.config.searchMediaBrowserItemsSortTitle || false; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetCategoryPlaylists(player.id, this.categoryId, 0, 0, country, limitTotal, sortResult) + .then(result => { + + // load media list results. + this.categoryPlaylists = result.items; + this.categoryPlaylistsLastUpdatedOn = result.date_last_refreshed || getUtcNowTimestamp(); + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.categoryPlaylists = undefined; + this.categoryPlaylistsLastUpdatedOn = 0; + + // call base class method, indicating media list update failed. + super.updatedMediaListError("Get Category Playlist failed: " + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetCategorysList); + + } else { + + // create promise - get browse categorys list. + const promiseGetCategorysList = new Promise((resolve, reject) => { + + // set service parameters. + const country = null; + const locale = null; + const refresh = true; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetBrowseCategorysList(player.id, country, locale, refresh) + .then(result => { + + // load media list results. + this.mediaList = result.items; + this.mediaListLastUpdatedOn = result.date_last_refreshed || getUtcNowTimestamp(); + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.mediaList = undefined; + this.mediaListLastUpdatedOn = 0; + + // call base class method, indicating media list update failed. + super.updatedMediaListError("Get Category List failed: " + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetCategorysList); + + } + + // show visual progress indicator. + this.progressShow(); + + // execute all promises, and wait for all of them to settle. + // we use `finally` logic so we can clear the progress indicator. + // any exceptions raised should have already been handled in the + // individual promise definitions; nothing else to do at this point. + Promise.allSettled(promiseRequests).finally(() => { + + // clear the progress indicator. + this.progressHide(); + + }); + + return true; + + } + catch (error) { + + // clear the progress indicator. + this.progressHide(); + + // set alert error message. + super.updatedMediaListError("Category List refresh failed: " + (error as Error).message); + return true; + + } + finally { + } + } + +} \ No newline at end of file diff --git a/src/sections/device-browser.ts b/src/sections/device-browser.ts index 018274f..3336c17 100644 --- a/src/sections/device-browser.ts +++ b/src/sections/device-browser.ts @@ -1,5 +1,5 @@ // lovelace card imports. -import { css, html, TemplateResult } from 'lit'; +import { html, TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; // our imports. @@ -7,10 +7,10 @@ import '../components/media-browser-list'; import '../components/media-browser-icons'; import '../components/device-actions'; import { FavBrowserBase } from './fav-browser-base'; -import { sharedStylesFavBrowser } from '../styles/shared-styles-fav-browser.js'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; import { formatTitleInfo } from '../utils/media-browser-utils'; +import { getUtcNowTimestamp } from '../utils/utils'; import { ISpotifyConnectDevice } from '../types/spotifyplus/spotify-connect-device'; // debug logging. @@ -103,21 +103,6 @@ export class DeviceBrowser extends FavBrowserBase { } - /** - * style definitions used by this component. - * */ - static get styles() { - - return [ - sharedStylesFavBrowser, - css` - - /* extra styles not defined in sharedStylesFavBrowser would go here. */ - ` - ]; - } - - protected override onFilterActionsClick(ev: MouseEvent) { // get action to perform. @@ -141,7 +126,8 @@ export class DeviceBrowser extends FavBrowserBase { * * @param args Event arguments that contain the media item that was clicked on. */ - protected override onItemSelected = (args: CustomEvent) => { + protected override onItemSelected(args: CustomEvent) { + //protected override onItemSelected = (args: CustomEvent) => { if (debuglog.enabled) { debuglog("onItemSelected - device item selected:\n%s", @@ -221,7 +207,7 @@ export class DeviceBrowser extends FavBrowserBase { // load media list results. this.mediaList = result.Items; - this.mediaListLastUpdatedOn = result.DateLastRefreshed || (Date.now() / 1000); + this.mediaListLastUpdatedOn = result.DateLastRefreshed || getUtcNowTimestamp(); // call base class method, indicating media list update succeeded. super.updatedMediaListOk(); @@ -237,7 +223,7 @@ export class DeviceBrowser extends FavBrowserBase { this.mediaListLastUpdatedOn = 0; // call base class method, indicating media list update failed. - super.updatedMediaListError("Get Spotify Connect Devices failed: \n" + (error as Error).message); + super.updatedMediaListError("Get Spotify Connect Devices failed: " + (error as Error).message); // reject the promise. reject(error); @@ -270,7 +256,7 @@ export class DeviceBrowser extends FavBrowserBase { this.progressHide(); // set alert error message. - super.updatedMediaListError("Spotify Connect Device refresh failed: \n" + (error as Error).message); + super.updatedMediaListError("Spotify Connect Device refresh failed: " + (error as Error).message); return true; } diff --git a/src/sections/episode-fav-browser.ts b/src/sections/episode-fav-browser.ts index a838920..e0c5768 100644 --- a/src/sections/episode-fav-browser.ts +++ b/src/sections/episode-fav-browser.ts @@ -1,5 +1,5 @@ // lovelace card imports. -import { css, html, TemplateResult } from 'lit'; +import { html, TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; // our imports. @@ -7,12 +7,12 @@ import '../components/media-browser-list'; import '../components/media-browser-icons'; import '../components/episode-actions'; import { FavBrowserBase } from './fav-browser-base'; -import { sharedStylesFavBrowser } from '../styles/shared-styles-fav-browser.js'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; import { formatTitleInfo } from '../utils/media-browser-utils'; -import { IEpisode } from '../types/spotifyplus/episode'; +import { getUtcNowTimestamp } from '../utils/utils'; import { GetEpisodes } from '../types/spotifyplus/episode-page-saved'; +import { IEpisode } from '../types/spotifyplus/episode'; @customElement("spc-episode-fav-browser") @@ -96,21 +96,6 @@ export class EpisodeFavBrowser extends FavBrowserBase { } - /** - * style definitions used by this component. - * */ - static get styles() { - - return [ - sharedStylesFavBrowser, - css` - - /* extra styles not defined in sharedStylesFavBrowser would go here. */ - ` - ]; - } - - /** * Updates the mediaList display. */ @@ -140,7 +125,7 @@ export class EpisodeFavBrowser extends FavBrowserBase { // load media list results. this.mediaList = GetEpisodes(result); - this.mediaListLastUpdatedOn = result.date_last_refreshed || (Date.now() / 1000); + this.mediaListLastUpdatedOn = result.date_last_refreshed || getUtcNowTimestamp(); // call base class method, indicating media list update succeeded. super.updatedMediaListOk(); @@ -156,7 +141,7 @@ export class EpisodeFavBrowser extends FavBrowserBase { this.mediaListLastUpdatedOn = 0; // call base class method, indicating media list update failed. - super.updatedMediaListError("Get Episode Favorites failed: \n" + (error as Error).message); + super.updatedMediaListError("Get Episode Favorites failed: " + (error as Error).message); // reject the promise. reject(error); @@ -189,7 +174,7 @@ export class EpisodeFavBrowser extends FavBrowserBase { this.progressHide(); // set alert error message. - super.updatedMediaListError("Episode favorites refresh failed: \n" + (error as Error).message); + super.updatedMediaListError("Episode favorites refresh failed: " + (error as Error).message); return true; } diff --git a/src/sections/fav-browser-base.ts b/src/sections/fav-browser-base.ts index 91605a0..0e95d3d 100644 --- a/src/sections/fav-browser-base.ts +++ b/src/sections/fav-browser-base.ts @@ -1,5 +1,5 @@ // lovelace card imports. -import { html, LitElement, PropertyValues, TemplateResult } from 'lit'; +import { css, html, LitElement, PropertyValues, TemplateResult } from 'lit'; import { property, query, state } from 'lit/decorators.js'; import { HomeAssistant } from 'custom-card-helpers'; import { @@ -8,6 +8,7 @@ import { } from '@mdi/js'; // our imports. +import { sharedStylesFavBrowser } from '../styles/shared-styles-fav-browser.js'; import { CardConfig } from '../types/card-config'; import { Section } from '../types/section'; import { Store } from '../model/store'; @@ -46,6 +47,8 @@ export class FavBrowserBase extends LitElement { @state() protected scrollTopSaved?: number; @state() protected mediaItem?: any; @state() protected filterCriteria?: string; + @state() protected isFilterCriteriaReadOnly?: boolean | null; + @state() protected isFilterCriteriaVisible?: boolean | null; // html form element objects. @query("#mediaBrowserContentElement", true) protected mediaBrowserContentElement!: HTMLDivElement; @@ -84,10 +87,11 @@ export class FavBrowserBase extends LitElement { /** Max number of items to return for a media list while editing the card configuration. */ protected EDITOR_LIMIT_TOTAL_MAX = 25; - /** Max number of items to return for a media list. */ + /** Max number of items to return for a media list (200). */ protected LIMIT_TOTAL_MAX = 200; protected filterCriteriaHtml; + protected filterCriteriaReadOnlyHtml; protected refreshMediaListHtml; protected btnHideActionsHtml; @@ -139,6 +143,10 @@ export class FavBrowserBase extends LitElement { // set scroll position (if needed). this.setScrollPosition(); + // enable filter criteria (by default). + this.isFilterCriteriaReadOnly = false; + this.isFilterCriteriaVisible = true; + // define control to render - search criteria. this.filterCriteriaHtml = html` `; + // define control to render - search criteria (readonly). + this.filterCriteriaReadOnlyHtml = html` + ${this.filterCriteria} + `; + // define control to render - search icon. this.refreshMediaListHtml = html` { + protected onItemSelected(args: CustomEvent) { if (debuglog.enabled) { debuglog("onItemSelected - media item selected:\n%s", @@ -467,7 +493,7 @@ export class FavBrowserBase extends LitElement { * * @param args Event arguments that contain the media item that was clicked on. */ - protected onItemSelectedWithHold = (args: CustomEvent) => { + protected onItemSelectedWithHold(args: CustomEvent) { if (debuglog.enabled) { debuglog("onItemSelectedWithHold - media item selected:\n%s", @@ -490,6 +516,11 @@ export class FavBrowserBase extends LitElement { // toggle action visibility. this.isActionsVisible = !this.isActionsVisible; + // clear any alerts if showing actions. + if (this.isActionsVisible) { + this.alertClear(); + } + }; @@ -610,6 +641,8 @@ export class FavBrowserBase extends LitElement { /** * Updates the mediaList display. * + * @param player MediaPlayer object that will process the request. + * * @returns False if the media list should not be updated; otherwise, True to update the media list. */ protected updateMediaList(player: MediaPlayer): boolean { @@ -651,7 +684,7 @@ export class FavBrowserBase extends LitElement { this.alertClear(); if (debuglog.enabled) { - debuglog("%c updateMediaList - updating medialist", + debuglog("%cupdateMediaList - updating medialist", "color: yellow;", ); } diff --git a/src/sections/playlist-fav-browser.ts b/src/sections/playlist-fav-browser.ts index bec8e16..36ee64c 100644 --- a/src/sections/playlist-fav-browser.ts +++ b/src/sections/playlist-fav-browser.ts @@ -1,5 +1,5 @@ // lovelace card imports. -import { css, html, TemplateResult } from 'lit'; +import { html, TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; // our imports. @@ -7,10 +7,10 @@ import '../components/media-browser-list'; import '../components/media-browser-icons'; import '../components/playlist-actions'; import { FavBrowserBase } from './fav-browser-base'; -import { sharedStylesFavBrowser } from '../styles/shared-styles-fav-browser.js'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; import { formatTitleInfo } from '../utils/media-browser-utils'; +import { getUtcNowTimestamp } from '../utils/utils'; import { IPlaylistSimplified } from '../types/spotifyplus/playlist-simplified'; @@ -95,21 +95,6 @@ export class PlaylistFavBrowser extends FavBrowserBase { } - /** - * style definitions used by this component. - * */ - static get styles() { - - return [ - sharedStylesFavBrowser, - css` - - /* extra styles not defined in sharedStylesFavBrowser would go here. */ - ` - ]; - } - - /** * Updates the mediaList display. */ @@ -139,7 +124,7 @@ export class PlaylistFavBrowser extends FavBrowserBase { // load media list results. this.mediaList = result.items; - this.mediaListLastUpdatedOn = result.date_last_refreshed || (Date.now() / 1000); + this.mediaListLastUpdatedOn = result.date_last_refreshed || getUtcNowTimestamp(); // call base class method, indicating media list update succeeded. super.updatedMediaListOk(); @@ -155,7 +140,7 @@ export class PlaylistFavBrowser extends FavBrowserBase { this.mediaListLastUpdatedOn = 0; // call base class method, indicating media list update failed. - super.updatedMediaListError("Get Playlist Followed failed: \n" + (error as Error).message); + super.updatedMediaListError("Get Playlist Followed failed: " + (error as Error).message); // reject the promise. reject(error); @@ -188,7 +173,7 @@ export class PlaylistFavBrowser extends FavBrowserBase { this.progressHide(); // set alert error message. - super.updatedMediaListError("Playlist followed refresh failed: \n" + (error as Error).message); + super.updatedMediaListError("Playlist followed refresh failed: " + (error as Error).message); return true; } diff --git a/src/sections/recent-browser.ts b/src/sections/recent-browser.ts index e9b2639..d2512bf 100644 --- a/src/sections/recent-browser.ts +++ b/src/sections/recent-browser.ts @@ -1,5 +1,5 @@ // lovelace card imports. -import { css, html, TemplateResult } from 'lit'; +import { html, TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; // our imports. @@ -7,12 +7,12 @@ import '../components/media-browser-list'; import '../components/media-browser-icons'; import '../components/track-actions'; import { FavBrowserBase } from './fav-browser-base'; -import { sharedStylesFavBrowser } from '../styles/shared-styles-fav-browser.js'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; import { formatTitleInfo } from '../utils/media-browser-utils'; -import { ITrack } from '../types/spotifyplus/track'; +import { getUtcNowTimestamp } from '../utils/utils'; import { GetTracks } from '../types/spotifyplus/track-page-saved'; +import { ITrack } from '../types/spotifyplus/track'; @customElement("spc-recent-browser") @@ -96,21 +96,6 @@ export class RecentBrowser extends FavBrowserBase { } - /** - * style definitions used by this component. - * */ - static get styles() { - - return [ - sharedStylesFavBrowser, - css` - - /* extra styles not defined in sharedStylesFavBrowser would go here. */ - ` - ]; - } - - /** * Updates the mediaList display. */ @@ -139,7 +124,7 @@ export class RecentBrowser extends FavBrowserBase { // load media list results. this.mediaList = GetTracks(result); - this.mediaListLastUpdatedOn = result.date_last_refreshed || (Date.now() / 1000); + this.mediaListLastUpdatedOn = result.date_last_refreshed || getUtcNowTimestamp(); // call base class method, indicating media list update succeeded. super.updatedMediaListOk(); @@ -155,7 +140,7 @@ export class RecentBrowser extends FavBrowserBase { this.mediaListLastUpdatedOn = 0; // call base class method, indicating media list update failed. - super.updatedMediaListError("Get Player Recent Tracks failed: \n" + (error as Error).message); + super.updatedMediaListError("Get Player Recent Tracks failed: " + (error as Error).message); // reject the promise. reject(error); @@ -188,7 +173,7 @@ export class RecentBrowser extends FavBrowserBase { this.progressHide(); // set alert error message. - super.updatedMediaListError("Recently Played items refresh failed: \n" + (error as Error).message); + super.updatedMediaListError("Recently Played items refresh failed: " + (error as Error).message); return true; } diff --git a/src/sections/search-media-browser.ts b/src/sections/search-media-browser.ts index 290b668..2a42794 100644 --- a/src/sections/search-media-browser.ts +++ b/src/sections/search-media-browser.ts @@ -2,12 +2,12 @@ import { css, html, TemplateResult } from 'lit'; import { customElement, query, state } from 'lit/decorators.js'; import { + mdiAccountMusic, mdiAlbum, mdiBookOpenVariant, mdiMicrophone, mdiMenuDown, mdiMusic, - mdiAccountMusic, mdiPlaylistPlay, mdiPodcast, } from '@mdi/js'; @@ -27,6 +27,8 @@ import { FavBrowserBase } from './fav-browser-base'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; import { formatTitleInfo } from '../utils/media-browser-utils'; +import { getUtcNowTimestamp } from '../utils/utils'; +import { getIdFromSpotifyUri } from '../services/spotifyplus-service'; import { storageService } from '../decorators/storage'; import { SearchMediaTypes } from '../types/search-media-types'; import { SearchMediaEventArgs } from '../events/search-media'; @@ -38,9 +40,14 @@ const debuglog = Debug(DEBUG_APP_NAME + ":search-media-browser"); /** Keys used to access cached storage items. */ const CACHE_KEY_SEARCH_MEDIA_TYPE = "_searchmediatype"; +const CACHE_KEY_SEARCH_EVENT_ARGS = "_searcheventargs"; const SEARCH_FOR_PREFIX = "Search for "; +// basic search types that require filter criteria. +const SEARCH_TYPES_BASIC = [SearchMediaTypes.ALBUMS, SearchMediaTypes.ARTISTS, SearchMediaTypes.AUDIOBOOKS, SearchMediaTypes.EPISODES, +SearchMediaTypes.PLAYLISTS, SearchMediaTypes.SHOWS, SearchMediaTypes.TRACKS] + @customElement("spc-search-media-browser") export class SearchBrowser extends FavBrowserBase { @@ -48,6 +55,7 @@ export class SearchBrowser extends FavBrowserBase { // private state properties. @state() private searchMediaType?: string; @state() private searchMediaTypeTitle?: string; + @state() private searchEventArgs?: SearchMediaEventArgs | null; // html form element objects. @query("#searchMediaType", false) private searchMediaTypeElement!: HTMLElement; @@ -84,6 +92,7 @@ export class SearchBrowser extends FavBrowserBase { const searchType = this.searchMediaType; let itemsPerRow = this.config.searchMediaBrowserItemsPerRow || 4; if (!(this.config.searchMediaBrowserUseDisplaySettings || false)) { + // general searches: if (searchType == SearchMediaTypes.ALBUMS) { itemsPerRow = this.config.albumFavBrowserItemsPerRow || 4; } else if (searchType == SearchMediaTypes.ARTISTS) { @@ -98,15 +107,42 @@ export class SearchBrowser extends FavBrowserBase { itemsPerRow = this.config.showFavBrowserItemsPerRow || 4; } else if (searchType == SearchMediaTypes.TRACKS) { itemsPerRow = this.config.trackFavBrowserItemsPerRow || 4; + // artists-specific searches: + } else if (searchType == SearchMediaTypes.ARTIST_ALBUMS) { + itemsPerRow = this.config.albumFavBrowserItemsPerRow || 4; + this.isFilterCriteriaReadOnly = true; + } else if (searchType == SearchMediaTypes.ARTIST_ALBUMS_APPEARSON) { + itemsPerRow = this.config.albumFavBrowserItemsPerRow || 4; + this.isFilterCriteriaReadOnly = true; + } else if (searchType == SearchMediaTypes.ARTIST_ALBUMS_COMPILATION) { + itemsPerRow = this.config.albumFavBrowserItemsPerRow || 4; + this.isFilterCriteriaReadOnly = true; + } else if (searchType == SearchMediaTypes.ARTIST_ALBUMS_SINGLE) { + itemsPerRow = this.config.albumFavBrowserItemsPerRow || 4; + this.isFilterCriteriaReadOnly = true; + } else if (searchType == SearchMediaTypes.ARTIST_RELATED_ARTISTS) { + itemsPerRow = this.config.artistFavBrowserItemsPerRow || 4; + this.isFilterCriteriaReadOnly = true; + } else if (searchType == SearchMediaTypes.ARTIST_TOP_TRACKS) { + itemsPerRow = this.config.trackFavBrowserItemsPerRow || 4; + this.isFilterCriteriaReadOnly = true; + // audiobook-specific searches: + } else if (searchType == SearchMediaTypes.AUDIOBOOK_EPISODES) { + itemsPerRow = this.config.episodeFavBrowserItemsPerRow || 4; + this.isFilterCriteriaReadOnly = true; + // show-specific searches: + } else if (searchType == SearchMediaTypes.SHOW_EPISODES) { + itemsPerRow = this.config.episodeFavBrowserItemsPerRow || 4; + this.isFilterCriteriaReadOnly = true; } } // update search media type if configuration options changed. - //if (this.isCardInEditPreview) { - if ((this.config.searchMediaBrowserSearchTypes) && (this.config.searchMediaBrowserSearchTypes.length > 0)) { + if ((!this.isFilterCriteriaReadOnly) && (this.config.searchMediaBrowserSearchTypes) && (this.config.searchMediaBrowserSearchTypes.length > 0)) { if (!(this.config.searchMediaBrowserSearchTypes?.includes(this.searchMediaType as SearchMediaTypes))) { - // hidden type is currently selected - reset current selection to first enabled. + // hidden type is currently selected - reset current selection to first enabled + // if a general search type is selected. this.searchMediaType = this.config.searchMediaBrowserSearchTypes[0]; this.searchMediaTypeTitle = SEARCH_FOR_PREFIX + this.searchMediaType; @@ -116,11 +152,16 @@ export class SearchBrowser extends FavBrowserBase { this.scrollTopSaved = 0; } } - //} + + // set flags that control search media type menu item visibility. + const isSearchArgsUriArtist = ((this.searchEventArgs?.uri || "").indexOf(":artist:") > -1); + const isSearchArgsUriAudiobook = (((this.searchEventArgs?.uri || "").indexOf(":show:") > -1) && (this.searchEventArgs?.subtype == "audiobook")); + const isSearchArgsUriShow = (((this.searchEventArgs?.uri || "").indexOf(":show:") > -1) && (this.searchEventArgs?.subtype == "podcast")); + const isSearchArgsUri = isSearchArgsUriArtist || isSearchArgsUriAudiobook || isSearchArgsUriShow; // define control to render - search media type. const searchMediaTypeHtml = html` - + @@ -152,6 +193,39 @@ export class SearchBrowser extends FavBrowserBase {
${SearchMediaTypes.TRACKS}
+ + + +
${SearchMediaTypes.ARTIST_TOP_TRACKS}
+
+ + +
${SearchMediaTypes.ARTIST_ALBUMS}
+
+ + +
${SearchMediaTypes.ARTIST_ALBUMS_COMPILATION}
+
+ + +
${SearchMediaTypes.ARTIST_ALBUMS_SINGLE}
+
+ + +
${SearchMediaTypes.ARTIST_ALBUMS_APPEARSON}
+
+ + +
${SearchMediaTypes.ARTIST_RELATED_ARTISTS}
+
+ + +
${SearchMediaTypes.AUDIOBOOK_EPISODES}
+
+ + +
${SearchMediaTypes.SHOW_EPISODES}
+
`; @@ -166,7 +240,11 @@ export class SearchBrowser extends FavBrowserBase { ${subtitle ? html`
${subtitle}
` : html``}
${!(this.isActionsVisible || false) ? html`` : html`${this.btnHideActionsHtml}`} - ${searchMediaTypeHtml}${this.filterCriteriaHtml}${this.refreshMediaListHtml} + ${searchMediaTypeHtml} + ${(this.isFilterCriteriaVisible) ? html` + ${(this.isFilterCriteriaReadOnly) ? html`${this.filterCriteriaReadOnlyHtml}` : html`${this.filterCriteriaHtml}`} + ` : html``} + ${this.refreshMediaListHtml}
${this.alertError ? html`${this.alertError}` : ""} @@ -179,7 +257,7 @@ export class SearchBrowser extends FavBrowserBase { html`` @@ -189,26 +267,27 @@ export class SearchBrowser extends FavBrowserBase { html`` ) } - // if actions are visbile, then render the actions display. - } else if (this.searchMediaType == SearchMediaTypes.ALBUMS) { + // if actions are visbile, then render the actions display. + } else if ([SearchMediaTypes.ALBUMS, SearchMediaTypes.ARTIST_ALBUMS, SearchMediaTypes.ARTIST_ALBUMS_APPEARSON, SearchMediaTypes.ARTIST_ALBUMS_COMPILATION, + SearchMediaTypes.ARTIST_ALBUMS_SINGLE].indexOf(this.searchMediaType as any) > -1) { return (html``); - } else if (this.searchMediaType == SearchMediaTypes.ARTISTS) { + } else if ([SearchMediaTypes.ARTISTS, SearchMediaTypes.ARTIST_RELATED_ARTISTS].indexOf(this.searchMediaType as any) > -1) { return (html``); } else if (this.searchMediaType == SearchMediaTypes.AUDIOBOOKS) { return (html``); - } else if (this.searchMediaType == SearchMediaTypes.EPISODES) { + } else if ([SearchMediaTypes.EPISODES, SearchMediaTypes.AUDIOBOOK_EPISODES, SearchMediaTypes.SHOW_EPISODES].indexOf(this.searchMediaType as any) > -1) { return (html``); - } else if (this.searchMediaType == SearchMediaTypes.PLAYLISTS) { + } else if ([SearchMediaTypes.PLAYLISTS].indexOf(this.searchMediaType as any) > -1) { return (html``); } else if (this.searchMediaType == SearchMediaTypes.SHOWS) { return (html``); - } else if (this.searchMediaType == SearchMediaTypes.TRACKS) { + } else if ([SearchMediaTypes.TRACKS, SearchMediaTypes.ARTIST_TOP_TRACKS].indexOf(this.searchMediaType as any) > -1) { return (html``); } else { return (html``); @@ -246,7 +325,7 @@ export class SearchBrowser extends FavBrowserBase { --md-menu-item-one-line-container-height: 2.0rem; /* menu item height */ display: inline-flex; flex-direction: row; - justify-content: left; + justify-content: space-between; } .search-media-browser-actions { @@ -299,7 +378,7 @@ export class SearchBrowser extends FavBrowserBase { } *[hide="false"] { - display: block; + display: flex; } ` ]; @@ -315,7 +394,9 @@ export class SearchBrowser extends FavBrowserBase { private hideSearchType(searchType: SearchMediaTypes) { if ((this.config.searchMediaBrowserSearchTypes) && (this.config.searchMediaBrowserSearchTypes.length > 0)) { - if (this.config.searchMediaBrowserSearchTypes?.includes(searchType)) { + if (this.config.searchMediaBrowserSearchTypes?.includes(searchType as SearchMediaTypes)) { + return false; // show searchType + } else if ((this.searchEventArgs) && (this.searchEventArgs?.searchCriteria == searchType)) { return false; // show searchType } else { return true; // hide searchType. @@ -341,6 +422,15 @@ export class SearchBrowser extends FavBrowserBase { // prepare to search. this.initSearchValues(args.searchType); this.filterCriteria = args.searchCriteria; + this.searchMediaType = args.searchType; + + if (SEARCH_TYPES_BASIC.includes(args.searchType)) { + // if search type is a basic search item, then just reset search event args. + this.searchEventArgs = null; + } else { + // otherwise, save the search arguments for later. + this.searchEventArgs = args; + } // execute the search. this.updateMediaList(this.player); @@ -357,20 +447,24 @@ export class SearchBrowser extends FavBrowserBase { super.storageValuesLoad(); // get default search type, based on enabled status. - // if none enabled, then use playlists; if playlists not enabled, then use first enabled. - let searchType = SearchMediaTypes.PLAYLISTS; - if ((this.config.searchMediaBrowserSearchTypes) && (this.config.searchMediaBrowserSearchTypes.length > 0)) { - if (!(this.config.searchMediaBrowserSearchTypes.includes(searchType))) { - searchType = ((this.config?.searchMediaBrowserSearchTypes[0] || "") as SearchMediaTypes); + // we only need to do this if it's a basic search type (e.g. filter criteria is not readonly). + // if none enabled, then use default; if default not enabled, then use first enabled. + let defaultSearchType = SearchMediaTypes.PLAYLISTS; + if (!this.isFilterCriteriaReadOnly) { + if ((this.config.searchMediaBrowserSearchTypes) && (this.config.searchMediaBrowserSearchTypes.length > 0)) { + if (!(this.config.searchMediaBrowserSearchTypes.includes(defaultSearchType))) { + defaultSearchType = ((this.config?.searchMediaBrowserSearchTypes[0] || "") as SearchMediaTypes); + } } } // load search-related values from the cache. - this.searchMediaType = storageService.getStorageValue(this.cacheKeyBase + this.mediaType + CACHE_KEY_SEARCH_MEDIA_TYPE, searchType); + this.searchMediaType = storageService.getStorageValue(this.cacheKeyBase + this.mediaType + CACHE_KEY_SEARCH_MEDIA_TYPE, defaultSearchType); + this.searchEventArgs = storageService.getStorageValue(this.cacheKeyBase + this.mediaType + CACHE_KEY_SEARCH_EVENT_ARGS, defaultSearchType); this.searchMediaTypeTitle = SEARCH_FOR_PREFIX + this.searchMediaType; if (debuglog.enabled) { - debuglog("storageValuesLoad - parameters loaded from cache: searchMediaType"); + debuglog("storageValuesLoad - parameters loaded from cache: searchMediaType, searchEventArgs"); } } @@ -386,9 +480,10 @@ export class SearchBrowser extends FavBrowserBase { // save search-related values to the cache. storageService.setStorageValue(this.cacheKeyBase + this.mediaType + CACHE_KEY_SEARCH_MEDIA_TYPE, this.searchMediaType); + storageService.setStorageValue(this.cacheKeyBase + this.mediaType + CACHE_KEY_SEARCH_EVENT_ARGS, this.searchEventArgs); if (debuglog.enabled) { - debuglog("storageValuesSave - parameters saved to cache: searchMediaType"); + debuglog("storageValuesSave - parameters saved to cache: searchMediaType, searchEventArgs"); } } @@ -402,6 +497,30 @@ export class SearchBrowser extends FavBrowserBase { private onSearchMediaTypeChanged(ev) { this.initSearchValues(ev.currentTarget.value); + const selValue = ev.currentTarget.value; + + if (debuglog.enabled) { + debuglog("onSearchMediaTypeChanged - selected value = %s ", + JSON.stringify(selValue) + ); + } + + if (SEARCH_TYPES_BASIC.includes(selValue)) { + + // if basic search item selected, then reset search event args. + this.searchEventArgs = null; + + } else if (this.searchEventArgs) { + + // if extended search item selected, then update the media list. + this.searchEventArgs.searchType = selValue; + this.updateMediaList(this.player); + + } else { + + debuglog("%conSearchMediaTypeChanged - searchEventArgs not set; event ignored!", "color:red"); + + } } @@ -419,6 +538,12 @@ export class SearchBrowser extends FavBrowserBase { return; } + if (debuglog.enabled) { + debuglog("initSearchValues - preparing to search for type: %s", + JSON.stringify(searchType), + ); + } + // store searchType and adjust the title. this.searchMediaType = searchType; this.searchMediaTypeTitle = SEARCH_FOR_PREFIX + this.searchMediaType; @@ -441,27 +566,29 @@ export class SearchBrowser extends FavBrowserBase { /** * Updates the mediaList display. + * + * @param player MediaPlayer object that will process the request. + * + * @returns Return value is ignored when called from the inheriting class. */ - protected override updateMediaList(player: MediaPlayer): boolean { - - if (debuglog.enabled) { - debuglog("%c updateMediaList - updating medialist", - "color: yellow;", - ); - } + protected override updateMediaList( + player: MediaPlayer, + //updateType: SearchMediaTypes | null = null, + ): boolean { // validations. - if (!this.filterCriteria) { - this.alertErrorSet("Please enter criteria to search for"); - this.filterCriteriaElement.focus(); - return false; - } if (!this.searchMediaType) { this.alertErrorSet("Please select the type of content to search for"); this.searchMediaTypeElement.focus(); return false; } + if ((!this.isFilterCriteriaReadOnly) && (!this.filterCriteria)) { + this.alertErrorSet("Please enter criteria to search for"); + this.filterCriteriaElement.focus(); + return false; + } + // invoke base class method; if it returns false, then we should not update the media list. if (!super.updateMediaList(player)) { return false; @@ -473,53 +600,426 @@ export class SearchBrowser extends FavBrowserBase { // that we can easily add promises if more data gathering is needed in the future. const promiseRequests = new Array>(); - // create promise - get media list. - const promiseUpdateMediaList = new Promise((resolve, reject) => { + if (debuglog.enabled) { + debuglog("%cupdateMediaList\n- mediaType = %s\n- searchMediaType = %s\n- searchEventArgs = %s", + "color:green", + JSON.stringify(this.mediaType), + JSON.stringify(this.searchMediaType), + JSON.stringify(this.searchEventArgs, null, 2) + ); + } - // update status. - this.alertInfo = "Searching Spotify " + this.searchMediaType + " catalog for \"" + this.filterCriteria + "\" ..."; + //// was main menu selected? + //if (this.searchMediaType == SearchMediaTypes.MAIN_MENU) { - // set service parameters. - const limitTotal = this.config.searchMediaBrowserSearchLimit || 50; - const market: string | undefined = undefined; // market code. - const includeExternal: string | undefined = undefined; // include_exclude code. + // // build media list. + // const result = new Array(); + // result.push({ name: SearchMediaTypes.ALBUM_NEW_RELEASES, subtitle: "", image_url: getMdiIconImageUrl(mdiAlbum), type: SEARCH_MENU_TYPE, uri: SEARCH_MENU_NEW_RELEASES }); + // //result.push({ name: SearchMediaTypes.PLAYLISTS_FEATURED, subtitle: "", image_url: getMdiIconImageUrl(mdiPlaylistPlay), type: SEARCH_MENU_TYPE, uri: SEARCH_MENU_FEATURED_PLAYLISTS }); - // call the service to retrieve the media list. - this.spotifyPlusService.Search(this.searchMediaType as SearchMediaTypes, player.id, this.filterCriteria || "", 0, 0, market, includeExternal, limitTotal) - .then(result => { + // // load media list results. + // this.mediaList = result; + // this.mediaListLastUpdatedOn = getUtcNowTimestamp(); - // load media list results. - this.mediaList = result.items as [any]; - this.mediaListLastUpdatedOn = result.date_last_refreshed || (Date.now() / 1000); + // if (debuglog.enabled) { + // debuglog("updateMediaList - Search Main Menu items:\n%s", + // JSON.stringify(result, null, 2) + // ); + // } - // clear certain info messsages if they are temporary. - if (this.alertInfo?.startsWith("Searching Spotify")) { - this.alertInfoClear(); - } + // // call base class method, indicating media list update succeeded. + // this.isUpdateInProgress = false; + // super.updatedMediaListOk(); + // return true; - // call base class method, indicating media list update succeeded. - super.updatedMediaListOk(); + if (SEARCH_TYPES_BASIC.includes(this.searchMediaType as any)) { - // resolve the promise. - resolve(true); + // create promise - basic search. + const promiseUpdateMediaList = new Promise((resolve, reject) => { - }) - .catch(error => { + // update status. + this.alertInfo = "Searching Spotify " + this.searchMediaType + " catalog for \"" + this.filterCriteria + "\" ..."; - // clear results, and reject the promise. - this.mediaList = undefined; - this.mediaListLastUpdatedOn = 0; + // set service parameters. + const limitTotal = this.config.searchMediaBrowserSearchLimit || 50; + const market: string | null = null; // market code. + const includeExternal: string | null = null; // include_exclude code. - // call base class method, indicating media list update failed. - super.updatedMediaListError("Spotify " + this.searchMediaType + " search failed: \n" + (error as Error).message); + // call the service to retrieve the media list. + this.spotifyPlusService.Search(this.searchMediaType as SearchMediaTypes, player.id, this.filterCriteria || "", 0, 0, market, includeExternal, limitTotal) + .then(result => { - // reject the promise. - reject(error); + // load media list results. + this.mediaList = result.items as [any]; + this.mediaListLastUpdatedOn = result.date_last_refreshed || getUtcNowTimestamp(); - }) - }); + // clear certain info messsages if they are temporary. + if (this.alertInfo?.startsWith("Searching ")) { + this.alertInfoClear(); + } + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.mediaList = undefined; + this.mediaListLastUpdatedOn = 0; + + // call base class method, indicating media list update failed. + super.updatedMediaListError("Spotify " + this.searchMediaType + " search failed: " + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseUpdateMediaList); + + } else if (this.searchMediaType == SearchMediaTypes.ARTIST_ALBUMS) { + + // create promise - get artists' compilation albums. + const promiseGetArtistAlbums = new Promise((resolve, reject) => { + + // update status. + this.alertInfo = "Searching " + this.searchMediaType + " for \"" + this.searchEventArgs?.title + "\" ..."; + + // set service parameters. + const artistId = getIdFromSpotifyUri(this.searchEventArgs?.uri); + const market = null; + const include_groups = "album"; + const limit_total = this.config.searchMediaBrowserSearchLimit || 50; + const sort_result = this.config.artistFavBrowserItemsSortTitle || false; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetArtistAlbums(player.id, artistId, include_groups, 0, 0, market, limit_total, sort_result) + .then(result => { + + // load media list results. + this.mediaList = result.items; + this.mediaListLastUpdatedOn = result.date_last_refreshed || getUtcNowTimestamp(); + + // clear certain info messsages if they are temporary. + if (this.alertInfo?.startsWith("Searching ")) { + this.alertInfoClear(); + } + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.mediaList = undefined; + this.mediaListLastUpdatedOn = 0; + + // call base class method, indicating media list update failed. + super.updatedMediaListError("Spotify " + this.searchMediaType + " search failed: " + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetArtistAlbums); - promiseRequests.push(promiseUpdateMediaList); + } else if (this.searchMediaType == SearchMediaTypes.ARTIST_ALBUMS_APPEARSON) { + + // create promise - get artists' compilation albums. + const promiseGetArtistAlbumsAppearsOn = new Promise((resolve, reject) => { + + // update status. + this.alertInfo = "Searching " + this.searchMediaType + " for \"" + this.searchEventArgs?.title + "\" ..."; + + // set service parameters. + const artistId = getIdFromSpotifyUri(this.searchEventArgs?.uri); + const market = null; + const include_groups = "appears_on"; + const limit_total = this.config.searchMediaBrowserSearchLimit || 50; + const sort_result = this.config.artistFavBrowserItemsSortTitle || false; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetArtistAlbums(player.id, artistId, include_groups, 0, 0, market, limit_total, sort_result) + .then(result => { + + // load media list results. + this.mediaList = result.items; + this.mediaListLastUpdatedOn = result.date_last_refreshed || getUtcNowTimestamp(); + + // clear certain info messsages if they are temporary. + if (this.alertInfo?.startsWith("Searching ")) { + this.alertInfoClear(); + } + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.mediaList = undefined; + this.mediaListLastUpdatedOn = 0; + + // call base class method, indicating media list update failed. + super.updatedMediaListError("Spotify " + this.searchMediaType + " search failed: " + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetArtistAlbumsAppearsOn); + + } else if (this.searchMediaType == SearchMediaTypes.ARTIST_ALBUMS_COMPILATION) { + + // create promise - get artists' compilation albums. + const promiseGetArtistAlbumsCompilation = new Promise((resolve, reject) => { + + // update status. + this.alertInfo = "Searching " + this.searchMediaType + " for \"" + this.searchEventArgs?.title + "\" ..."; + + // set service parameters. + const artistId = getIdFromSpotifyUri(this.searchEventArgs?.uri); + const market = null; + const include_groups = "compilation"; + const limit_total = this.config.searchMediaBrowserSearchLimit || 50; + const sort_result = this.config.artistFavBrowserItemsSortTitle || false; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetArtistAlbums(player.id, artistId, include_groups, 0, 0, market, limit_total, sort_result) + .then(result => { + + // load media list results. + this.mediaList = result.items; + this.mediaListLastUpdatedOn = result.date_last_refreshed || getUtcNowTimestamp(); + + // clear certain info messsages if they are temporary. + if (this.alertInfo?.startsWith("Searching ")) { + this.alertInfoClear(); + } + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.mediaList = undefined; + this.mediaListLastUpdatedOn = 0; + + // call base class method, indicating media list update failed. + super.updatedMediaListError("Spotify " + this.searchMediaType + " search failed: " + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetArtistAlbumsCompilation); + + } else if (this.searchMediaType == SearchMediaTypes.ARTIST_ALBUMS_SINGLE) { + + // create promise - get artists' compilation albums. + const promiseGetArtistAlbumsSingle = new Promise((resolve, reject) => { + + // update status. + this.alertInfo = "Searching " + this.searchMediaType + " for \"" + this.searchEventArgs?.title + "\" ..."; + + // set service parameters. + const artistId = getIdFromSpotifyUri(this.searchEventArgs?.uri); + const market = null; + const include_groups = "single"; + const limit_total = this.config.searchMediaBrowserSearchLimit || 50; + const sort_result = this.config.artistFavBrowserItemsSortTitle || false; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetArtistAlbums(player.id, artistId, include_groups, 0, 0, market, limit_total, sort_result) + .then(result => { + + // load media list results. + this.mediaList = result.items; + this.mediaListLastUpdatedOn = result.date_last_refreshed || getUtcNowTimestamp(); + + // clear certain info messsages if they are temporary. + if (this.alertInfo?.startsWith("Searching ")) { + this.alertInfoClear(); + } + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.mediaList = undefined; + this.mediaListLastUpdatedOn = 0; + + // call base class method, indicating media list update failed. + super.updatedMediaListError("Spotify " + this.searchMediaType + " search failed: " + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetArtistAlbumsSingle); + + } else if (this.searchMediaType == SearchMediaTypes.ARTIST_RELATED_ARTISTS) { + + // create promise - get artists' related artists. + const promiseGetArtistRelatedArtists = new Promise((resolve, reject) => { + + // update status. + this.alertInfo = "Searching " + this.searchMediaType + " for \"" + this.searchEventArgs?.title + "\" ..."; + + // set service parameters. + const artistId = getIdFromSpotifyUri(this.searchEventArgs?.uri); + const sortResult = this.config.searchMediaBrowserItemsSortTitle || false; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetArtistRelatedArtists(player.id, artistId, sortResult) + .then(result => { + + // load media list results. + this.mediaList = result; + this.mediaListLastUpdatedOn = getUtcNowTimestamp(); + + // clear certain info messsages if they are temporary. + if (this.alertInfo?.startsWith("Searching ")) { + this.alertInfoClear(); + } + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.mediaList = undefined; + this.mediaListLastUpdatedOn = 0; + + // call base class method, indicating media list update failed. + super.updatedMediaListError("Spotify " + this.searchMediaType + " search failed: " + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetArtistRelatedArtists); + + } else if (this.searchMediaType == SearchMediaTypes.ARTIST_TOP_TRACKS) { + + // create promise - get artists' top tracks. + const promiseGetArtistTopTracks = new Promise((resolve, reject) => { + + // update status. + this.alertInfo = "Searching " + this.searchMediaType + " for \"" + this.searchEventArgs?.title + "\" ..."; + + // set service parameters. + const artistId = getIdFromSpotifyUri(this.searchEventArgs?.uri); + const sortResult = this.config.searchMediaBrowserItemsSortTitle || false; + const market = null; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetArtistTopTracks(player.id, artistId, market, sortResult) + .then(result => { + + // load media list results. + this.mediaList = result; + this.mediaListLastUpdatedOn = getUtcNowTimestamp(); + + // clear certain info messsages if they are temporary. + if (this.alertInfo?.startsWith("Searching ")) { + this.alertInfoClear(); + } + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.mediaList = undefined; + this.mediaListLastUpdatedOn = 0; + + // call base class method, indicating media list update failed. + super.updatedMediaListError("Spotify " + this.searchMediaType + " search failed: " + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetArtistTopTracks); + + } else if (this.searchMediaType == SearchMediaTypes.SHOW_EPISODES) { + + // create promise - get show episodes. + const promiseGetShowEpisodes = new Promise((resolve, reject) => { + + // update status. + this.alertInfo = "Searching " + this.searchMediaType + " for \"" + this.searchEventArgs?.title + "\" ..."; + + // set service parameters. + const showId = getIdFromSpotifyUri(this.searchEventArgs?.uri); + const market = null; + const limit_total = this.config.searchMediaBrowserSearchLimit || 50; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetShowEpisodes(player.id, showId, 0, 0, market, limit_total) + .then(result => { + + // load media list results. + this.mediaList = result.items; + this.mediaListLastUpdatedOn = result.date_last_refreshed || getUtcNowTimestamp(); + + // clear certain info messsages if they are temporary. + if (this.alertInfo?.startsWith("Searching ")) { + this.alertInfoClear(); + } + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.mediaList = undefined; + this.mediaListLastUpdatedOn = 0; + + // call base class method, indicating media list update failed. + super.updatedMediaListError("Spotify " + this.searchMediaType + " search failed: " + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetShowEpisodes); + + } else { + + // if no search type was selected, then log it as we will need to modify code. + this.isUpdateInProgress = false; + + console.log("%cSpotifyPlus Card: updateMediaList - searchMediaType was not processed:\n- mediaType = %s\n- searchMediaType = %s\n- searchEventArgs = %s", + "color:red", + JSON.stringify(this.mediaType), + JSON.stringify(this.searchMediaType), + JSON.stringify(this.searchEventArgs, null, 2) + ); + + } // show visual progress indicator. this.progressShow(); @@ -544,7 +1044,7 @@ export class SearchBrowser extends FavBrowserBase { this.progressHide(); // set alert error message. - super.updatedMediaListError("Spotify " + this.searchMediaType + " search failed: \n" + (error as Error).message); + super.updatedMediaListError("Spotify " + this.searchMediaType + " search failed: " + (error as Error).message); return true; } diff --git a/src/sections/show-fav-browser.ts b/src/sections/show-fav-browser.ts index 94d03b1..3ec8b8c 100644 --- a/src/sections/show-fav-browser.ts +++ b/src/sections/show-fav-browser.ts @@ -1,5 +1,5 @@ // lovelace card imports. -import { css, html, TemplateResult } from 'lit'; +import { html, TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; // our imports. @@ -7,12 +7,12 @@ import '../components/media-browser-list'; import '../components/media-browser-icons'; import '../components/show-actions'; import { FavBrowserBase } from './fav-browser-base'; -import { sharedStylesFavBrowser } from '../styles/shared-styles-fav-browser.js'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; import { formatTitleInfo } from '../utils/media-browser-utils'; -import { IShowSimplified } from '../types/spotifyplus/show-simplified'; +import { getUtcNowTimestamp } from '../utils/utils'; import { GetShows } from '../types/spotifyplus/show-page-saved'; +import { IShowSimplified } from '../types/spotifyplus/show-simplified'; @customElement("spc-show-fav-browser") @@ -96,21 +96,6 @@ export class ShowFavBrowser extends FavBrowserBase { } - /** - * style definitions used by this component. - * */ - static get styles() { - - return [ - sharedStylesFavBrowser, - css` - - /* extra styles not defined in sharedStylesFavBrowser would go here. */ - ` - ]; - } - - /** * Updates the mediaList display. */ @@ -141,7 +126,7 @@ export class ShowFavBrowser extends FavBrowserBase { // load media list results. this.mediaList = GetShows(result); - this.mediaListLastUpdatedOn = result.date_last_refreshed || (Date.now() / 1000); + this.mediaListLastUpdatedOn = result.date_last_refreshed || getUtcNowTimestamp(); // call base class method, indicating media list update succeeded. super.updatedMediaListOk(); @@ -157,7 +142,7 @@ export class ShowFavBrowser extends FavBrowserBase { this.mediaListLastUpdatedOn = 0; // call base class method, indicating media list update failed. - super.updatedMediaListError("Get Show Favorites failed: \n" + (error as Error).message); + super.updatedMediaListError("Get Show Favorites failed: " + (error as Error).message); // reject the promise. reject(error); @@ -190,7 +175,7 @@ export class ShowFavBrowser extends FavBrowserBase { this.progressHide(); // set alert error message. - super.updatedMediaListError("Show favorites refresh failed: \n" + (error as Error).message); + super.updatedMediaListError("Show favorites refresh failed: " + (error as Error).message); return true; } diff --git a/src/sections/track-fav-browser.ts b/src/sections/track-fav-browser.ts index 61877d2..19e24a5 100644 --- a/src/sections/track-fav-browser.ts +++ b/src/sections/track-fav-browser.ts @@ -1,5 +1,5 @@ // lovelace card imports. -import { css, html, TemplateResult } from 'lit'; +import { html, TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; // our imports. @@ -7,12 +7,12 @@ import '../components/media-browser-list'; import '../components/media-browser-icons'; import '../components/track-actions'; import { FavBrowserBase } from './fav-browser-base'; -import { sharedStylesFavBrowser } from '../styles/shared-styles-fav-browser.js'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; import { formatTitleInfo } from '../utils/media-browser-utils'; -import { ITrack } from '../types/spotifyplus/track'; +import { getUtcNowTimestamp } from '../utils/utils'; import { GetTracks } from '../types/spotifyplus/track-page-saved'; +import { ITrack } from '../types/spotifyplus/track'; @customElement("spc-track-fav-browser") @@ -96,21 +96,6 @@ export class TrackFavBrowser extends FavBrowserBase { } - /** - * style definitions used by this component. - * */ - static get styles() { - - return [ - sharedStylesFavBrowser, - css` - - /* extra styles not defined in sharedStylesFavBrowser would go here. */ - ` - ]; - } - - /** * Updates the mediaList display. */ @@ -133,7 +118,7 @@ export class TrackFavBrowser extends FavBrowserBase { // set service parameters. const limitTotal = this.LIMIT_TOTAL_MAX; // max # of items to return const sortResult = this.config.trackFavBrowserItemsSortTitle || false; - const market = undefined; // market code. + const market = null; // market code. // call the service to retrieve the media list. this.spotifyPlusService.GetTrackFavorites(player.id, 0, 0, market, limitTotal, sortResult) @@ -141,7 +126,7 @@ export class TrackFavBrowser extends FavBrowserBase { // load media list results. this.mediaList = GetTracks(result); - this.mediaListLastUpdatedOn = result.date_last_refreshed || (Date.now() / 1000); + this.mediaListLastUpdatedOn = result.date_last_refreshed || getUtcNowTimestamp(); // call base class method, indicating media list update succeeded. super.updatedMediaListOk(); @@ -157,7 +142,7 @@ export class TrackFavBrowser extends FavBrowserBase { this.mediaListLastUpdatedOn = 0; // call base class method, indicating media list update failed. - super.updatedMediaListError("Get Track Favorites failed: \n" + (error as Error).message); + super.updatedMediaListError("Get Track Favorites failed: " + (error as Error).message); // reject the promise. reject(error); @@ -190,7 +175,7 @@ export class TrackFavBrowser extends FavBrowserBase { this.progressHide(); // set alert error message. - super.updatedMediaListError("Track favorites refresh failed: \n" + (error as Error).message); + super.updatedMediaListError("Track favorites refresh failed: " + (error as Error).message); return true; } diff --git a/src/sections/userpreset-browser.ts b/src/sections/userpreset-browser.ts index d274f38..d0b953d 100644 --- a/src/sections/userpreset-browser.ts +++ b/src/sections/userpreset-browser.ts @@ -1,5 +1,5 @@ // lovelace card imports. -import { css, html, TemplateResult } from 'lit'; +import { html, TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; // our imports. @@ -7,10 +7,10 @@ import '../components/media-browser-list'; import '../components/media-browser-icons'; import '../components/userpreset-actions'; import { FavBrowserBase } from './fav-browser-base'; -import { sharedStylesFavBrowser } from '../styles/shared-styles-fav-browser.js'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; import { formatTitleInfo } from '../utils/media-browser-utils'; +import { getUtcNowTimestamp } from '../utils/utils'; import { IUserPreset } from '../types/spotifyplus/user-preset'; @@ -95,21 +95,91 @@ export class UserPresetBrowser extends FavBrowserBase { } - /** - * style definitions used by this component. - * */ - static get styles() { + /** + * Handles the `item-selected` event fired when a media browser item is clicked. + * + * @param evArgs Event arguments that contain the media item that was clicked on. + */ + protected override onItemSelected(evArgs: CustomEvent) { + + // event could contains an IUserPreset item. + const eventType = evArgs.detail.type; + + // is this a recommendations type? + if (eventType == "recommendations") { + + const mediaItem = evArgs.detail as IUserPreset; + this.PlayTrackRecommendations(mediaItem); - return [ - sharedStylesFavBrowser, - css` + } else { + + // category playlist was selected; event argument is an IPlayListSimplified item. + // just call base class method to play the media item (it's a playlist). + super.onItemSelected(evArgs); + + } - /* extra styles not defined in sharedStylesFavBrowser would go here. */ - ` - ]; } + /** + * Calls the SpotifyPlusService PlayerMediaPlayTracks method to play all tracks + * returned by the GetTrackRecommendations service for the desired track attributes. + * + * @param preset The user preset item that was selected. + */ + protected async PlayTrackRecommendations(preset: IUserPreset): Promise { + + try { + + // show progress indicator. + this.progressShow(); + + // update status. + this.alertInfo = "Searching for track recommendations ..."; + this.requestUpdate(); + + // get track recommendations. + const limit = 50; + const result = await this.spotifyPlusService.GetTrackRecommendations(this.player.id, preset.recommendations, limit, null); + + // build track uri list from recommendation results. + const uris = new Array(); + result.tracks.forEach(item => { + uris.push(item.uri); + }); + + // check for no matching tracks. + if (uris.length == 0) { + this.alertInfo = "No recommended tracks were found for the preset criteria; adjust the preset criteria settings and try again."; + return; + } + + // update status. + this.alertInfo = "Playing recommended tracks ..."; + this.requestUpdate(); + + // play recommended tracks. + await this.spotifyPlusService.PlayerMediaPlayTracks(this.player.id, uris.join(","), null, null); + + // show player section. + this.store.card.SetSection(Section.PLAYER); + + } + catch (error) { + + // set error message and reset scroll position to zero so the message is displayed. + this.alertErrorSet("Could not get track recommendations for user preset. " + (error as Error).message); + this.mediaBrowserContentElement.scrollTop = 0; + + } + finally { + + // hide progress indicator. + this.progressHide(); + + } + } /** @@ -125,7 +195,7 @@ export class UserPresetBrowser extends FavBrowserBase { try { // initialize the media list, as we are loading it from multiple sources. - this.mediaListLastUpdatedOn = (Date.now() / 1000); + this.mediaListLastUpdatedOn = getUtcNowTimestamp(); this.mediaList = new Array(); // we use the `Promise.allSettled` approach here like we do with actions, so @@ -154,7 +224,7 @@ export class UserPresetBrowser extends FavBrowserBase { catch (error) { // reject the promise. - super.updatedMediaListError("Load User Presets from config failed: \n" + (error as Error).message); + super.updatedMediaListError("Load User Presets from config failed: " + (error as Error).message); reject(error); } @@ -227,7 +297,7 @@ export class UserPresetBrowser extends FavBrowserBase { this.progressHide(); // set alert error message. - super.updatedMediaListError("User Presets favorites refresh failed: \n" + (error as Error).message); + super.updatedMediaListError("User Presets favorites refresh failed: " + (error as Error).message); return true; } diff --git a/src/services/hass-service.ts b/src/services/hass-service.ts index 448fa7e..0d2c9aa 100644 --- a/src/services/hass-service.ts +++ b/src/services/hass-service.ts @@ -40,7 +40,7 @@ export class HassService { try { if (debuglog.enabled) { - debuglog("%c CallService - Calling service %s (no response)\n%s", + debuglog("%cCallService - Calling service %s (no response)\n%s", "color: orange;", JSON.stringify(serviceRequest.service), JSON.stringify(serviceRequest, null, 2), diff --git a/src/services/spotifyplus-service.ts b/src/services/spotifyplus-service.ts index 596fe12..8cd92f2 100644 --- a/src/services/spotifyplus-service.ts +++ b/src/services/spotifyplus-service.ts @@ -24,6 +24,7 @@ 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 { ICategoryPage } from '../types/spotifyplus/category-page'; import { IChapter } from '../types/spotifyplus/chapter'; import { IChapterPageSimplified } from '../types/spotifyplus/chapter-page-simplified'; import { IEpisode } from '../types/spotifyplus/episode'; @@ -43,6 +44,8 @@ import { ITrack } from '../types/spotifyplus/track'; import { ITrackPage } from '../types/spotifyplus/track-page'; import { ITrackPageSaved } from '../types/spotifyplus/track-page-saved'; import { ITrackPageSimplified } from '../types/spotifyplus/track-page-simplified'; +import { ITrackRecommendations } from '../types/spotifyplus/track-recommendations'; +import { ITrackRecommendationsProperties } from '../types/spotifyplus/track-recommendations-properties'; // debug logging. import Debug from 'debug/src/browser.js'; @@ -118,7 +121,7 @@ export class SpotifyPlusService { */ public async CallServiceWithResponse( serviceRequest: ServiceCallRequest, - ): Promise { + ): Promise> { try { @@ -147,86 +150,20 @@ export class SpotifyPlusService { //if (debuglog.enabled) { // debuglog("%cCallServiceWithResponse - Service %s response:\n%s", - // "color: orange", + // "color: red", // 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) + return serviceResponse.response || {}; } finally { } } - - /** - * Returns the "result" portion of a SpotifyPlus service response that contains - * the "user_profile" and "result" keys. - * - * @param jsonString JSON response string - */ - private _GetJsonStringResult(jsonString: string): string { - - let result: string = ''; - const RESULT_KEY: string = '"result":' - const RESULT_KEY_LEN: number = RESULT_KEY.length; - - // does service response containe a "result" key? - const idx: number = jsonString.indexOf(RESULT_KEY); - const jsonStringLen: number = jsonString.length; - - //console.log("%c _GetJsonStringResult (spotifyplus-service)\n idx = %s\n length = %s", - // "color: gold;", - // JSON.stringify(idx), - // JSON.stringify(jsonStringLen) - //); - - if (idx > -1) { - - // return the "result" key portion of the response. - result = jsonString.substring(idx + RESULT_KEY_LEN, jsonStringLen - 1); - } - - //console.log("%c _GetJsonStringResult (spotifyplus-service) result string:\n%s", - // "color: gold;", - // JSON.stringify(result), - //); - - return result; - } - - - ///** - // * Returns the "user_profile" portion of a SpotifyPlus service response that contains - // * the "user_profile" and "result" keys. - // * - // * @param jsonString JSON response string - //*/ - //private _GetJsonStringUserProfile(jsonString: string): string { - - // let result: string = ''; - // const RESULT_KEY: string = '"result":{' - // const USERPROFILE_KEY: string = '"user_profile":{' - // const USERPROFILE_KEY_LEN: number = USERPROFILE_KEY.length; - - // // does service response contain a "result" key? - // const idx: number = jsonString.indexOf(USERPROFILE_KEY); - // const idxEnd: number = jsonString.indexOf(RESULT_KEY); - // if (idx > -1) { - - // // return the "user_profile" key portion of the response, surrounded by the - // // opening and closing brackets to simulate a complete JSON response. - // result = '{' + jsonString.substring(1 + USERPROFILE_KEY_LEN, idxEnd - 2) + '}'; - // } - - // //console.log("%cspotifyplus-service._GetJsonStringUserProfile()\n result string:\n%s", "color: gold;", result); - // return result; - //} - - /** * Add one or more items to the end of the user's current Spotify Player playback queue. * @@ -309,11 +246,7 @@ export class SpotifyPlusService { // 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); - return responseObj; + return response["result"]; } finally { @@ -354,11 +287,7 @@ export class SpotifyPlusService { // 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); - return responseObj; + return response["result"]; } finally { @@ -399,11 +328,7 @@ export class SpotifyPlusService { // 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); - return responseObj; + return response["result"]; } finally { @@ -444,11 +369,7 @@ export class SpotifyPlusService { // 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); - return responseObj; + return response["result"]; } finally { @@ -490,11 +411,7 @@ export class SpotifyPlusService { // 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); - return responseObj; + return response["result"]; } finally { @@ -535,11 +452,7 @@ export class SpotifyPlusService { // 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); - return responseObj; + return response["result"]; } finally { @@ -580,11 +493,7 @@ export class SpotifyPlusService { // 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); - return responseObj; + return response["result"]; } finally { @@ -712,8 +621,7 @@ export class SpotifyPlusService { 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 IAlbum; + const responseObj = response["result"] as IAlbum; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -796,13 +704,7 @@ export class SpotifyPlusService { 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 IAlbumPageSaved; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); - - //throw new Error("Test exception thrown in GetAlbumFavorites method."); // TEST TODO REMOVEME + const responseObj = response["result"] as IAlbumPageSaved; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -887,11 +789,7 @@ export class SpotifyPlusService { 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 ITrackPageSimplified; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as ITrackPageSimplified; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -980,11 +878,7 @@ export class SpotifyPlusService { 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 IAlbumPageSimplified; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IAlbumPageSimplified; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -1050,11 +944,7 @@ export class SpotifyPlusService { 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 IArtistInfo; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IArtistInfo; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -1122,11 +1012,7 @@ export class SpotifyPlusService { 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); + const responseObj = response["result"] as Array; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -1199,11 +1085,7 @@ export class SpotifyPlusService { 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); + const responseObj = response["result"] as Array; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -1282,11 +1164,7 @@ export class SpotifyPlusService { 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 IArtistPage; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IArtistPage; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -1356,8 +1234,7 @@ export class SpotifyPlusService { 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; + const responseObj = response["result"] as IAudiobook; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -1444,11 +1321,7 @@ export class SpotifyPlusService { 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 IChapterPageSimplified; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IChapterPageSimplified; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -1527,11 +1400,7 @@ export class SpotifyPlusService { 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 IAudiobookPageSimplified; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IAudiobookPageSimplified; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -1562,6 +1431,163 @@ export class SpotifyPlusService { } + /** + * Get a sorted list of ALL categories used to tag items in Spotify. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param country 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. The country associated with the user account will take priority over this parameter. + * @param locale The desired language, consisting of a lowercase ISO 639-1 language code and an uppercase ISO 3166-1 alpha-2 country code, joined by an underscore. For example `es_MX`, meaning `Spanish (Mexico)`. Provide this parameter if you want the results returned in a particular language (where available). Note that if locale is not supplied, or if the specified language is not available, all strings will be returned in the Spotify default language (American English). + * @param refresh True to return real-time information from the spotify web api and update the cache; otherwise, False to just return the cached value. + * @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 ICategoryPage object. + */ + public async GetBrowseCategorysList( + entity_id: string, + country: string | undefined | null = null, + locale: string | undefined | null = null, + refresh: 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 (country) + serviceData['country'] = country; + if (locale) + serviceData['locale'] = locale; + if (refresh) + serviceData['refresh'] = refresh; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_browse_categorys_list', + 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 responseObj = response["result"] as ICategoryPage; + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + responseObj.items.forEach(item => { + item.icons = []; + }) + } + } + + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. + return responseObj; + + } + finally { + } + } + + + /** + * Get a list of Spotify playlists tagged with a particular category. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param category_id Spotify category ID (not name) for the category. + * @param limit The maximum number of items to return in a page of items when manual paging is used. Default is 20, Range is 1 to 50. See the limit_total argument for automatic paging option. + * @param offset The index of the first item to return. Use with limit to get the next set of items. Default: 0(the first item). + * @param country 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 limit_total If specified, this argument overrides the limit and offset argument values and paging is automatically used to retrieve all available items up to the maximum count specified. Default: None(disabled) + * @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 An IPlaylistPageSimplified object. + */ + public async GetCategoryPlaylists( + entity_id: string, + category_id: string | undefined | null = null, + limit: number | null = null, + offset: number | null = null, + country: string | undefined | null = null, + limit_total: number | 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, + category_id: category_id, + }; + + // update service data parameters (with optional parameters). + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + if (country) + serviceData['country'] = country; + if (limit_total) + serviceData['limit_total'] = limit_total; + if (sort_result) + serviceData['sort_result'] = sort_result; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_category_playlists', + 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 responseObj = response["result"] as IPlaylistPageSimplified; + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + responseObj.items.forEach(item => { + item.images = []; + }) + } + } + + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. + return responseObj; + + } + finally { + } + } + + /** * Get Spotify catalog information for a single audiobook chapter identified by its unique Spotify ID. * @@ -1602,8 +1628,7 @@ export class SpotifyPlusService { 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 IChapter; + const responseObj = response["result"] as IChapter; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -1677,8 +1702,7 @@ export class SpotifyPlusService { 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 IEpisode; + const responseObj = response["result"] as IEpisode; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -1760,11 +1784,7 @@ export class SpotifyPlusService { 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 IEpisodePageSaved; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IEpisodePageSaved; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -1826,11 +1846,7 @@ export class SpotifyPlusService { 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 IPlayerQueueInfo; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IPlayerQueueInfo; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -1874,10 +1890,6 @@ export class SpotifyPlusService { } } - // set the lastUpdatedOn value to epoch (number of seconds), as the - // service does not provide this field (but we need it for media list processing). - responseObj.date_last_refreshed = responseObj.date_last_refreshed || (Date.now() / 1000); - // trace. if (debuglog.enabled) { debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", @@ -1948,11 +1960,7 @@ export class SpotifyPlusService { 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 IPlayHistoryPage; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IPlayHistoryPage; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -2031,11 +2039,7 @@ export class SpotifyPlusService { 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 IPlaylistPageSimplified; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IPlaylistPageSimplified; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -2125,11 +2129,7 @@ export class SpotifyPlusService { 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 IPlaylistPage; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IPlaylistPage; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -2184,7 +2184,7 @@ export class SpotifyPlusService { try { if (debuglog.enabled) { - debuglog("%c GetSpotifyConnectDevices - retrieving device list from %s", + debuglog("%cGetSpotifyConnectDevices - retrieving device list from %s", "color: orange;", (refresh) ? "real-time query" : "internal device cache", ); @@ -2212,11 +2212,7 @@ export class SpotifyPlusService { 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 ISpotifyConnectDevices; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as ISpotifyConnectDevices; // set image_url property based on device type. if ((responseObj != null) && (responseObj.Items != null)) { @@ -2305,11 +2301,7 @@ export class SpotifyPlusService { 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 IEpisodePageSimplified; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IEpisodePageSimplified; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -2391,11 +2383,7 @@ export class SpotifyPlusService { 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 IShowPageSaved; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IShowPageSaved; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -2462,8 +2450,7 @@ export class SpotifyPlusService { 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 ITrack; + const responseObj = response["result"] as ITrack; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -2544,11 +2531,7 @@ export class SpotifyPlusService { 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 ITrackPageSaved; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as ITrackPageSaved; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -2579,6 +2562,191 @@ export class SpotifyPlusService { } + /** + * Get track recommendations for specified criteria. + * + * Use the `GetTrackAudioFeatures` method to get an idea of what to specify for some of the + * minX / maxX / and targetX recommendations values. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param recommendations + * @param limit + * @param market + * @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 `ITrackRecommendations` object that contains the track details. + */ + public async GetTrackRecommendations( + entity_id: string, + recommendations: ITrackRecommendationsProperties | undefined | null = null, + limit: number | undefined | null = null, + market: string | undefined | 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 (limit) + serviceData['limit'] = limit; + if (market) + serviceData['market'] = market; + + if (recommendations) { + + if (recommendations.seed_artists) + serviceData['seed_artists'] = recommendations.seed_artists; + if (recommendations.seed_genres) + serviceData['seed_genres'] = recommendations.seed_genres; + if (recommendations.seed_tracks) + serviceData['seed_tracks'] = recommendations.seed_tracks; + + if (recommendations.max_acousticness) + serviceData['max_acousticness'] = recommendations.max_acousticness; + if (recommendations.min_acousticness) + serviceData['min_acousticness'] = recommendations.min_acousticness; + if (recommendations.target_acousticness) + serviceData['target_acousticness'] = recommendations.target_acousticness; + + if (recommendations.max_danceability) + serviceData['max_danceability'] = recommendations.max_danceability; + if (recommendations.min_danceability) + serviceData['min_danceability'] = recommendations.min_danceability; + if (recommendations.target_danceability) + serviceData['target_danceability'] = recommendations.target_danceability; + + if (recommendations.max_duration_ms) + serviceData['max_duration_ms'] = recommendations.max_duration_ms; + if (recommendations.min_duration_ms) + serviceData['min_duration_ms'] = recommendations.min_duration_ms; + if (recommendations.target_duration_ms) + serviceData['target_duration_ms'] = recommendations.target_duration_ms; + + if (recommendations.max_energy) + serviceData['max_energy'] = recommendations.max_energy; + if (recommendations.min_energy) + serviceData['min_energy'] = recommendations.min_energy; + if (recommendations.target_energy) + serviceData['target_energy'] = recommendations.target_energy; + + if (recommendations.max_instrumentalness) + serviceData['max_instrumentalness'] = recommendations.max_instrumentalness; + if (recommendations.min_instrumentalness) + serviceData['min_instrumentalness'] = recommendations.min_instrumentalness; + if (recommendations.target_instrumentalness) + serviceData['target_instrumentalness'] = recommendations.target_instrumentalness; + + if (recommendations.max_key) + serviceData['max_key'] = recommendations.max_key; + if (recommendations.min_key) + serviceData['min_key'] = recommendations.min_key; + if (recommendations.target_key) + serviceData['target_key'] = recommendations.target_key; + + if (recommendations.max_liveness) + serviceData['max_liveness'] = recommendations.max_liveness; + if (recommendations.min_liveness) + serviceData['min_liveness'] = recommendations.min_liveness; + if (recommendations.target_liveness) + serviceData['target_liveness'] = recommendations.target_liveness; + + if (recommendations.max_loudness) + serviceData['max_loudness'] = recommendations.max_loudness; + if (recommendations.min_loudness) + serviceData['min_loudness'] = recommendations.min_loudness; + if (recommendations.target_loudness) + serviceData['target_loudness'] = recommendations.target_loudness; + + if (recommendations.max_mode) + serviceData['max_mode'] = recommendations.max_mode; + if (recommendations.min_mode) + serviceData['min_mode'] = recommendations.min_mode; + if (recommendations.target_mode) + serviceData['target_mode'] = recommendations.target_mode; + + if (recommendations.max_popularity) + serviceData['max_popularity'] = recommendations.max_popularity; + if (recommendations.min_popularity) + serviceData['min_popularity'] = recommendations.min_popularity; + if (recommendations.target_popularity) + serviceData['target_popularity'] = recommendations.target_popularity; + + if (recommendations.max_speechiness) + serviceData['max_speechiness'] = recommendations.max_speechiness; + if (recommendations.min_speechiness) + serviceData['min_speechiness'] = recommendations.min_speechiness; + if (recommendations.target_speechiness) + serviceData['target_speechiness'] = recommendations.target_speechiness; + + if (recommendations.max_tempo) + serviceData['max_tempo'] = recommendations.max_tempo; + if (recommendations.min_tempo) + serviceData['min_tempo'] = recommendations.min_tempo; + if (recommendations.target_tempo) + serviceData['target_tempo'] = recommendations.target_tempo; + + if (recommendations.max_time_signature) + serviceData['max_time_signature'] = recommendations.max_time_signature; + if (recommendations.min_time_signature) + serviceData['min_time_signature'] = recommendations.min_time_signature; + if (recommendations.target_time_signature) + serviceData['target_time_signature'] = recommendations.target_time_signature; + + if (recommendations.max_valence) + serviceData['max_valence'] = recommendations.max_valence; + if (recommendations.min_valence) + serviceData['min_valence'] = recommendations.min_valence; + if (recommendations.target_valence) + serviceData['target_valence'] = recommendations.target_valence; + + } + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_track_recommendations', + 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 responseObj = response["result"] as ITrackRecommendations; + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if (responseObj != null) { + responseObj.tracks.forEach(track => { + track.available_markets = []; + track.album.available_markets = []; + track.album.images = []; + }) + } + } + + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. + return responseObj; + + } + finally { + } + } + + /** * Start playing one or more tracks of the specified context on a Spotify Connect device. * @@ -3133,7 +3301,7 @@ export class SpotifyPlusService { */ public async Search( searchMediaType: SearchMediaTypes.ALBUMS | SearchMediaTypes.ARTISTS | SearchMediaTypes.AUDIOBOOKS | SearchMediaTypes.EPISODES | - SearchMediaTypes.PLAYLISTS | SearchMediaTypes.SHOWS | SearchMediaTypes.TRACKS, + SearchMediaTypes.PLAYLISTS | SearchMediaTypes.SHOWS | SearchMediaTypes.TRACKS | string, entity_id: string, criteria: string, limit: number | null = null, @@ -3225,11 +3393,7 @@ export class SpotifyPlusService { 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 IAlbumPageSimplified; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IAlbumPageSimplified; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -3314,11 +3478,7 @@ export class SpotifyPlusService { 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 IArtistPage; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IArtistPage; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -3402,11 +3562,7 @@ export class SpotifyPlusService { 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 IAudiobookPageSimplified; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IAudiobookPageSimplified; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -3492,11 +3648,7 @@ export class SpotifyPlusService { 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 IEpisodePageSimplified; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IEpisodePageSimplified; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -3581,11 +3733,7 @@ export class SpotifyPlusService { 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 IPlaylistPageSimplified; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IPlaylistPageSimplified; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -3669,11 +3817,7 @@ export class SpotifyPlusService { 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 IShowPageSimplified; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as IShowPageSimplified; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { @@ -3759,11 +3903,7 @@ export class SpotifyPlusService { 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 ITrackPage; - - //// get the "user_profile" portion of the response, and convert it to a type. - //this._GetJsonStringUserProfile(response); + const responseObj = response["result"] as ITrackPage; // omit some data from the results, as it's not necessary and conserves memory. if (trimResults) { diff --git a/src/styles/shared-styles-fav-browser.js b/src/styles/shared-styles-fav-browser.js index ecd7ca5..3c2e4eb 100644 --- a/src/styles/shared-styles-fav-browser.js +++ b/src/styles/shared-styles-fav-browser.js @@ -61,7 +61,14 @@ export const sharedStylesFavBrowser = css` padding-right: 0.5rem; padding-left: 0.5rem; width: 100%; - /* min-width: 300px; */ + } + + .media-browser-control-filter-disabled { + padding-right: 0.5rem; + padding-left: 0.5rem; + width: 100%; + align-self: center; + color: var(--dark-primary-color); } .media-browser-content { diff --git a/src/types/card-config.ts b/src/types/card-config.ts index 3c2b90c..95ebe7d 100644 --- a/src/types/card-config.ts +++ b/src/types/card-config.ts @@ -172,6 +172,46 @@ export interface CardConfig extends LovelaceCardConfig { */ audiobookFavBrowserItemsSortTitle?: boolean; + /** + * Title displayed at the top of the Category media browser section form. + * Omit this parameter to hide the title display area. + * This value supports Title Formatter Options. + */ + categoryBrowserTitle?: string; + + /** + * Sub-title displayed at the top of the Category media browser section form. + * Omit this parameter to hide the sub-title display area. + * This value supports Title Formatter Options. + */ + categoryBrowserSubTitle?: string; + + /** + * Number of items to display in a single row of the Category media browser section form. + * Use a value of 1 to display the items as a vertical list. + * Default is 3. + */ + categoryBrowserItemsPerRow?: number; + + /** + * Hide titles displayed for Category media browser items. + * Default is false. + */ + categoryBrowserItemsHideTitle?: boolean; + + /** + * Hide sub-titles displayed for Category media browser items. + * Default is false. + */ + categoryBrowserItemsHideSubTitle?: boolean; + + /** + * True to sort displayed Category Playlist media browser item titles by name; + * Otherwise, False to display in the order returned from the Spotify Web API. + * Default is false. + */ + categoryBrowserItemsSortTitle?: boolean; + /** * Title displayed at the top of the Device browser section form. * Omit this parameter to hide the title display area. diff --git a/src/types/config-area.ts b/src/types/config-area.ts index e9681e2..c79ffca 100644 --- a/src/types/config-area.ts +++ b/src/types/config-area.ts @@ -5,6 +5,7 @@ export enum ConfigArea { ALBUM_FAVORITES = 'Albums', ARTIST_FAVORITES = 'Artists', AUDIOBOOK_FAVORITES = 'Audiobooks', + CATEGORY_BROWSER = 'Categorys', DEVICE_BROWSER = 'Devices', EPISODE_FAVORITES = 'Episodes', GENERAL = 'General', diff --git a/src/types/search-media-types.ts b/src/types/search-media-types.ts index 9b8a3e8..945b0ae 100644 --- a/src/types/search-media-types.ts +++ b/src/types/search-media-types.ts @@ -3,11 +3,33 @@ */ export enum SearchMediaTypes { - ALBUMS = 'Albums', - ARTISTS = 'Artists', - AUDIOBOOKS = 'AudioBooks', - EPISODES = 'Episodes', - PLAYLISTS = 'Playlists', - SHOWS = 'Shows', - TRACKS = 'Tracks', + + // general search types. + ALBUMS = "Albums", + ARTISTS = "Artists", + AUDIOBOOKS = "AudioBooks", + EPISODES = "Episodes", + PLAYLISTS = "Playlists", + SHOWS = "Shows", + TRACKS = "Tracks", + + // album-specific search types. + ALBUM_NEW_RELEASES = "Album New Releases", + + // artist-specific search types. + ARTIST_ALBUMS = "Artist Albums", + ARTIST_ALBUMS_APPEARSON = "Artist Album ApearsOn", + ARTIST_ALBUMS_COMPILATION = "Artist Album Compilations", + ARTIST_ALBUMS_SINGLE = "Artist Album Singles", + ARTIST_RELATED_ARTISTS = "Artist Related Artists", + ARTIST_TOP_TRACKS = "Artist Top Tracks", + + // audiobook-specific search types. + AUDIOBOOK_EPISODES = "Audiobook Chapters", + + // show-specific search types. + SHOW_EPISODES = "Show Episodes", + + // ui-specific search types. + MAIN_MENU = "Menu", } diff --git a/src/types/section.ts b/src/types/section.ts index acfcc62..6d7ef6c 100644 --- a/src/types/section.ts +++ b/src/types/section.ts @@ -5,6 +5,7 @@ export enum Section { ALBUM_FAVORITES = 'albumfavorites', ARTIST_FAVORITES = 'artistfavorites', AUDIOBOOK_FAVORITES = 'audiobookfavorites', + CATEGORYS = 'categorys', DEVICES = 'devices', EPISODE_FAVORITES = 'episodefavorites', PLAYER = 'player', diff --git a/src/types/spotifyplus/artist-info-tour-event.ts b/src/types/spotifyplus/artist-info-tour-event.ts index 87aa562..f488167 100644 --- a/src/types/spotifyplus/artist-info-tour-event.ts +++ b/src/types/spotifyplus/artist-info-tour-event.ts @@ -12,6 +12,12 @@ export interface IArtistInfoTourEvent { /** + * Link to the concert information, if supplied; otherwise, null. + */ + href: string | null; + + + /** * Title given to the event by the promoter, if supplied; otherwise, null. */ title: string | null; diff --git a/src/types/spotifyplus/artist-info.ts b/src/types/spotifyplus/artist-info.ts index 1846897..54ed23a 100644 --- a/src/types/spotifyplus/artist-info.ts +++ b/src/types/spotifyplus/artist-info.ts @@ -31,12 +31,18 @@ export interface IArtistInfo { /** - * Biography text. + * Biography text, in plain-text format. */ bio: string | null; /** + * Biography text, in html format. + */ + bio_html: string | null; + + + /** * The Spotify ID for the artist. */ id: string; diff --git a/src/types/spotifyplus/category-page.ts b/src/types/spotifyplus/category-page.ts new file mode 100644 index 0000000..b393bd9 --- /dev/null +++ b/src/types/spotifyplus/category-page.ts @@ -0,0 +1,18 @@ +import { ICategory } from './category'; +import { IPageObject } from './page-object'; + +/** + * Spotify Web API CategoryPage object. + * + * This allows for multiple pages of `Category` objects to be navigated. + */ +export interface ICategoryPage extends IPageObject { + + + /** + * Array of `ICategory` objects. + */ + items: Array; + + +} diff --git a/src/types/spotifyplus/category.ts b/src/types/spotifyplus/category.ts new file mode 100644 index 0000000..5c8fb60 --- /dev/null +++ b/src/types/spotifyplus/category.ts @@ -0,0 +1,65 @@ +import { IImageObject } from './image-object'; + +/** + * Spotify Web API Category object. + */ +export interface ICategory { + + /** + * A link to the Web API endpoint returning full details of the category. + */ + href: string; + + + /** + * The Spotify category ID of the category. + * Some ID's are read-able text, while most are a unique id format. + * + * Example: `toplists` + * Example: `0JQ5DAqbMKFDXXwE9BDJAr` (e.g. unique id for `Rock`) + */ + id: string; + + + /** + * The category icon in various sizes, widest first. + */ + icons: Array; + + + /** + * First icon url in the `Icons` list, if images are defined; + * otherwise, null. + * + * This will default to the first image in the `images` collection if not set, courtesy of + * the `media_browser_utils.getContentItemImageUrl()` method. + */ + image_url?: string | undefined; + + + /** + * Name of the category. + */ + name: string; + + + /** + * The object type: `category`. + * + * This is a helper property - no value with this name is returned from the + * Spotify Web API. + */ + type: string; + + + /** + * A simulated Spotify URI value for the category. + * + * This is a helper property - no value with this name is returned from the + * Spotify Web API. + * + * Example: `spotify:category:0JQ5DAqbMKFDXXwE9BDJAr` + */ + uri: string; + +} diff --git a/src/types/spotifyplus/page-object.ts b/src/types/spotifyplus/page-object.ts index ee1dfea..2c38873 100644 --- a/src/types/spotifyplus/page-object.ts +++ b/src/types/spotifyplus/page-object.ts @@ -2,6 +2,7 @@ import { IAlbumSaved } from './album-saved'; import { IAlbumSimplified } from './album-simplified'; import { IArtist } from './artist'; import { IAudiobookSimplified } from './audiobook-simplified'; +import { ICategory } from './category'; import { IChapterSimplified } from './chapter-simplified'; import { IUserPreset } from './user-preset'; import { IEpisodeSaved } from './episode-saved'; @@ -59,13 +60,13 @@ export interface IPageObject { * * Example: `https://api.spotify.com/v1/me/shows?offset=0&limit=20` * */ - href: string; + href?: string; /** * True if cursors were returned at some point during the life of this paging object. * */ - is_cursor: boolean; + is_cursor?: boolean; /** @@ -73,9 +74,9 @@ export interface IPageObject { * * This property will be overrriden by inheriting classes. */ - items: Array; + items: Array; /** @@ -98,7 +99,7 @@ export interface IPageObject { * This property can be modified in case the paging request needs to be adjusted * based upon overall request limits. * */ - limit: number; + limit?: number; /** @@ -106,7 +107,7 @@ export interface IPageObject { * * Example: `https://api.spotify.com/v1/me/shows?offset=1&limit=1` * */ - next: string; + next?: string; /** @@ -123,7 +124,7 @@ export interface IPageObject { * * Example: `https://api.spotify.com/v1/me/shows?offset=1&limit=1` * */ - previous: string; + previous?: string; /** diff --git a/src/types/spotifyplus/recommendation-seed.ts b/src/types/spotifyplus/recommendation-seed.ts new file mode 100644 index 0000000..6d553f1 --- /dev/null +++ b/src/types/spotifyplus/recommendation-seed.ts @@ -0,0 +1,51 @@ +/** + * Spotify Web API Content RecommendationSeed object. + * + * Contains information about recommended tracks. + */ +export interface IRecommendationSeed { + + /** + * The number of tracks available after min_* and max_* filters have been applied. + */ + after_filtering_size: number; + + + /** + * The number of tracks available after relinking for regional availability. + */ + after_relinking_size: number; + + + /** + * A link to the full track or artist data for this seed. + * + * For tracks this will be a link to a Track Object. + * For artists a link to an Artist Object. + * For genre seeds, this value will be null. + */ + href?: string; + + + /** + * The id used to select this seed. + * + * This will be the same as the string used in the seedArtists, seedTracks or seedGenres parameter. + */ + id?: string; + + + /** + * The number of recommended tracks available for this seed. + */ + initial_pool_size: number; + + + /** + * The entity type of this seed. + * + * One of `artist`, `track` or `genre`. + */ + type?: string; + +} diff --git a/src/types/spotifyplus/track-recommendations-properties.ts b/src/types/spotifyplus/track-recommendations-properties.ts new file mode 100644 index 0000000..32b739c --- /dev/null +++ b/src/types/spotifyplus/track-recommendations-properties.ts @@ -0,0 +1,320 @@ +/** + * Properties used in the IUserPreset object for call to the GetTrackRecommendations service. + * + * Use the `GetTrackAudioFeatures` method to get an idea of what to specify for some of the + * minX / maxX / and targetX recommendations values. + */ +export interface ITrackRecommendationsProperties { + + /** + * A comma separated list of Spotify IDs for seed artists. + * Up to 5 seed values may be provided in any combination of seedArtists, seedTracks and seedGenres. + * Note: only required if seedGenres and seedTracks are not set. + * Example: `4NHQUGzhtTLFvgF5SZesLK` + */ + seed_artists?: string; + + + /** + * A comma separated list of any genres in the set of available genre seeds. + * Up to 5 seed values may be provided in any combination of seedArtists, seedTracks and seedGenres. + * Note: only required if seedArtists and seedTracks are not set. + * Example: `classical,country` + */ + seed_genres?: string; + + + /** + * A comma separated list of Spotify IDs for a seed track. + * Up to 5 seed values may be provided in any combination of seedArtists, seedTracks and seedGenres. + * Note: only required if seedArtists and seedGenres are not set. + * Example: `0c6xIDDpzE81m2q797ordA` + */ + seed_tracks?: string; + + + /** + * Restrict results to only those tracks whose acousticness level is greater than the specified value. + * Range: `0` - `1` + */ + min_acousticness?: number; + + + /** + * Restrict results to only those tracks whose acousticness level is less than the specified value. + * Range: `0` - `1` + */ + max_acousticness?: number; + + + /** + * Restrict results to only those tracks whose acousticness level is equal to the specified value. + * Range: `0` - `1` + */ + target_acousticness?: number; + + + /** + * Restrict results to only those tracks whose danceability level is greater than the specified value. + * Range: `0` - `1` + */ + min_danceability?: number; + + + /** + * Restrict results to only those tracks whose danceability level is less than the specified value. + * Range: `0` - `1` + */ + max_danceability?: number; + + + /** + * Restrict results to only those tracks whose acousticness is equal to the specified value. + * Range: `0` - `1` + */ + target_danceability?: number; + + + /** + * Restrict results to only those tracks whose duration is greater than the specified value in milliseconds. + */ + min_duration_ms?: number; + + + /** + * Restrict results to only those tracks whose duration is less than the specified value in milliseconds. + */ + max_duration_ms?: number; + + + /** + * Restrict results to only those tracks whose duration is equal to the specified value in milliseconds. + */ + target_duration_ms?: number; + + + /** + * Restrict results to only those tracks whose energy level is greater than the specified value. + * Range: `0` - `1` + */ + min_energy?: number; + + + /** + * Restrict results to only those tracks whose energy level is less than the specified value. + * Range: `0` - `1` + */ + max_energy?: number; + + + /** + * Restrict results to only those tracks whose energy level is equal to the specified value. + * Range: `0` - `1` + */ + target_energy?: number; + + + /** + * Restrict results to only those tracks whose instrumentalness level is greater than the specified value. + * Range: `0` - `1` + */ + min_instrumentalness?: number; + + + /** + * Restrict results to only those tracks whose instrumentalness level is less than the specified value. + * Range: `0` - `1` + */ + max_instrumentalness?: number; + + + /** + * Restrict results to only those tracks whose instrumentalness level is equal to the specified value. + * Range: `0` - `1` + */ + target_instrumentalness?: number; + + + /** + * Restrict results to only those tracks whose key level is greater than the specified value. + * Range: `0` - `11` + */ + min_key?: number; + + + /** + * Restrict results to only those tracks whose key level is less than the specified value. + * Range: `0` - `11` + */ + max_key?: number; + + + /** + * Restrict results to only those tracks whose key level is equal to the specified value. + * Range: `0` - `11` + */ + target_key?: number; + + + /** + * Restrict results to only those tracks whose liveness level is greater than the specified value. + * Range: `0` - `1` + */ + min_liveness?: number; + + + /** + * Restrict results to only those tracks whose liveness level is less than the specified value. + * Range: `0` - `1` + */ + max_liveness?: number; + + + /** + * Restrict results to only those tracks whose liveness level is equal to the specified value. + * Range: `0` - `1` + */ + target_liveness?: number; + + + /** + * Restrict results to only those tracks whose loudness level is greater than the specified value. + */ + min_loudness?: number; + + + /** + * Restrict results to only those tracks whose loudness level is less than the specified value. + */ + max_loudness?: number; + + + /** + * Restrict results to only those tracks whose loudness level is equal to the specified value. + */ + target_loudness?: number; + + + /** + * Restrict results to only those tracks whose mode level is greater than the specified value. + * Range: `0` - `1` + */ + min_mode?: number; + + + /** + * Restrict results to only those tracks whose mode level is less than the specified value. + * Range: `0` - `1` + */ + max_mode?: number; + + + /** + * Restrict results to only those tracks whose mode level is equal to the specified value. + * Range: `0` - `1` + */ + target_mode?: number; + + + /** + * Restrict results to only those tracks whose popularity level is greater than the specified value. + * Range: `0` - `100` + */ + min_popularity?: number; + + + /** + * Restrict results to only those tracks whose popularity level is less than the specified value. + * Range: `0` - `100` + */ + max_popularity?: number; + + + /** + * Restrict results to only those tracks whose popularity level is equal to the specified value. + * Range: `0` - `100` + */ + target_popularity?: number; + + + /** + * Restrict results to only those tracks whose speechiness level is greater than the specified value. + * Range: `0` - `1` + */ + min_speechiness?: number; + + + /** + * Restrict results to only those tracks whose speechiness level is less than the specified value. + * Range: `0` - `1` + */ + max_speechiness?: number; + + + /** + * Restrict results to only those tracks whose speechiness level is equal to the specified value. + * Range: `0` - `1` + */ + target_speechiness?: number; + + + /** + * Restrict results to only those tracks with a tempo greater than the specified number of beats per minute. + */ + min_tempo?: number; + + + /** + * Restrict results to only those tracks with a tempo less than the specified number of beats per minute. + */ + max_tempo?: number; + + + /** + * Restrict results to only those tracks with a tempo equal to the specified number of beats per minute. + */ + target_tempo?: number; + + + /** + * Restrict results to only those tracks whose time signature is greater than the specified value. + * Maximum value: 11 + */ + min_time_signature?: number; + + + /** + * Restrict results to only those tracks whose time signature is less than the specified value. + * Maximum value: 11 + */ + max_time_signature?: number; + + + /** + * Restrict results to only those tracks whose time signature is equal to the specified value. + * Maximum value: 11 + */ + target_time_signature?: number; + + + /** + * Restrict results to only those tracks whose valence level is greater than the specified value. + * Range: `0` - `1` + */ + min_valence?: number; + + + /** + * Restrict results to only those tracks whose valence level is less than the specified value. + * Range: `0` - `1` + */ + max_valence?: number; + + + /** + * Restrict results to only those tracks whose valence level is equal to the specified value. + * Range: `0` - `1` + */ + target_valence?: number; + +} \ No newline at end of file diff --git a/src/types/spotifyplus/track-recommendations.ts b/src/types/spotifyplus/track-recommendations.ts new file mode 100644 index 0000000..61bec96 --- /dev/null +++ b/src/types/spotifyplus/track-recommendations.ts @@ -0,0 +1,21 @@ +import { IRecommendationSeed } from './recommendation-seed'; +import { ITrack } from './track'; + +/** + * Spotify Web API TrackRecommendations object. + */ +export interface ITrackRecommendations { + + /** + * A list of recommendation seed objects. + */ + seeds: Array; + + + /** + * A list of Track objects, ordered according to the parameters supplied + * to the `GetTrackRecommendations` method. + */ + tracks: Array; + +} diff --git a/src/types/spotifyplus/user-preset.ts b/src/types/spotifyplus/user-preset.ts index 7d45d9e..8c5e373 100644 --- a/src/types/spotifyplus/user-preset.ts +++ b/src/types/spotifyplus/user-preset.ts @@ -2,6 +2,8 @@ * User preset item configuration object. */ +import { ITrackRecommendationsProperties } from "./track-recommendations-properties"; + export interface IUserPreset { /** @@ -19,7 +21,7 @@ export interface IUserPreset { /** * Origin location of the content item (e.g. `config`, `file`). */ - origin: string | null; + origin?: string | null; /** @@ -39,4 +41,40 @@ export interface IUserPreset { */ uri: string | null; + + /** + * Properties used for calls to the GetTrackRecommendations service. or null. + * This property should only be populated for type = "recommendations". + */ + recommendations?: ITrackRecommendationsProperties | null; + +} + + +/** +* Gets a text-representation of an `IUserPreset` object, which can then be pasted into +* the card configuration under the `userPresets:` key. +* +* @param mediaItem A media item object that contains the following properties: name, type, image_url, and uri. +* @param subTitle Value to use for the sub-title text; null value will use the mediaItem type value. +* @returns An array of `ITrack` objects that exist in the collection; otherwise, an empty array. +*/ +export function GetUserPresetConfigEntry( + mediaItem: any, + subTitle: string | undefined | null = null, +): string { + + const CRLF = "\n"; + + // create text-representation of user preset object. + let presetText = ""; + presetText += " - name: " + mediaItem.name + CRLF; + presetText += " subtitle: " + (subTitle || mediaItem.type) + CRLF; + presetText += " image_url: " + mediaItem.image_url + CRLF; + presetText += " uri: " + mediaItem.uri + CRLF; + presetText += " type: " + mediaItem.type + CRLF; + + // return to caller. + return presetText; + } diff --git a/src/utils/config-util.ts b/src/utils/config-util.ts new file mode 100644 index 0000000..979fae5 --- /dev/null +++ b/src/utils/config-util.ts @@ -0,0 +1,543 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import type { Connection } from "home-assistant-js-websocket"; + +//import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; +//import { LovelaceCardConfig } from "../../../data/lovelace/config/card"; +//import { +// LovelaceSectionRawConfig, +// isStrategySection, +//} from "../../../data/lovelace/config/section"; +//import { LovelaceConfig } from "../../../data/lovelace/config/types"; +//import { +// LovelaceViewRawConfig, +// isStrategyView, +//} from "../../../data/lovelace/config/view"; + + +//export const addCards = ( +// config: LovelaceConfig, +// path: LovelaceContainerPath, +// cardConfigs: LovelaceCardConfig[] +//): LovelaceConfig => { +// const cards = findLovelaceItems("cards", config, path); +// const newCards = cards ? [...cards, ...cardConfigs] : [...cardConfigs]; +// const newConfig = updateLovelaceItems("cards", config, path, newCards); +// return newConfig; +//}; + +export const replaceCard = ( + config: LovelaceConfig, + path: LovelaceCardPath, + cardConfig: LovelaceCardConfig +): LovelaceConfig => { + const { cardIndex } = parseLovelaceCardPath(path); + const containerPath = getLovelaceContainerPath(path); + + const cards = findLovelaceItems("cards", config, containerPath); + + const newCards = (cards ?? []).map((origConf, ind) => + ind === cardIndex ? cardConfig : origConf + ); + + const newConfig = updateLovelaceItems( + "cards", + config, + containerPath, + newCards + ); + return newConfig; +}; + + + + + + +export interface Lovelace { + config: LovelaceConfig; + rawConfig: LovelaceRawConfig; + editMode: boolean; + urlPath: string | null; + mode: "generated" | "yaml" | "storage"; + locale: any; + enableFullEditMode: () => void; + setEditMode: (editMode: boolean) => void; + saveConfig: (newConfig: LovelaceRawConfig) => Promise; + deleteConfig: () => Promise; + showToast: (params: any) => void; +} + +export function getLovelace(): Lovelace | null { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let root: any = document.querySelector('home-assistant'); + root = root && root.shadowRoot; + root = root && root.querySelector('home-assistant-main'); + root = root && root.shadowRoot; + root = root && root.querySelector('app-drawer-layout partial-panel-resolver, ha-drawer partial-panel-resolver'); + root = (root && root.shadowRoot) || root; + root = root && root.querySelector('ha-panel-lovelace'); + root = root && root.shadowRoot; + root = root && root.querySelector('hui-root'); + if (root) { + // dump property keys of HUI-ROOT: + //for (const key of Object.keys(root)) { + // //console.log("root property key: " + key) // + ": " + root[key]); + // console.log("root property key: " + key + " = " + root[key]); + //} + const ll = root.lovelace; + //if (!ll) { + // console.log("%cLL root.lovelace not found - getting root.__lovelace", "color:red"); + // ll = root.__lovelace; + //} + //if (!ll) { + // console.log("%cLL root.lovelace not found - getting root[__lovelace]", "color:red"); + // ll = root["__lovelace"]; + //} + //console.log("%cLL 06 = %s", "color:red", ll) + ll.current_view = root.___curView; + return ll; + } + return null; +} + + + +export type LovelaceCardPath = [number, number] | [number, number, number]; +export type LovelaceContainerPath = [number] | [number, number]; + +export const parseLovelaceCardPath = ( + path: LovelaceCardPath +): { viewIndex: number; sectionIndex?: number; cardIndex: number } => { + if (path.length === 2) { + return { + viewIndex: path[0], + cardIndex: path[1], + }; + } + return { + viewIndex: path[0], + sectionIndex: path[1], + cardIndex: path[2], + }; +}; + +export const parseLovelaceContainerPath = ( + path: LovelaceContainerPath +): { viewIndex: number; sectionIndex?: number } => { + if (path.length === 1) { + return { + viewIndex: path[0], + }; + } + return { + viewIndex: path[0], + sectionIndex: path[1], + }; +}; + +export const getLovelaceContainerPath = ( + path: LovelaceCardPath +): LovelaceContainerPath => path.slice(0, -1) as LovelaceContainerPath; + + +type LovelaceItemKeys = { + cards: LovelaceCardConfig[]; + badges: (Partial | string)[]; +}; + +export const updateLovelaceItems = ( + key: T, + config: LovelaceConfig, + path: LovelaceContainerPath, + items: LovelaceItemKeys[T] +): LovelaceConfig => { + const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path); + + let updated = false; + const newViews = config.views.map((view, vIndex) => { + if (vIndex !== viewIndex) return view; + if (isStrategyView(view)) { + throw new Error(`Can not update ${key} in a strategy view`); + } + if (sectionIndex === undefined) { + updated = true; + return { + ...view, + [key]: items, + }; + } + + if (view.sections === undefined) { + throw new Error("Section does not exist"); + } + + const newSections = view.sections.map((section, sIndex) => { + if (sIndex !== sectionIndex) return section; + if (isStrategySection(section)) { + throw new Error(`Can not update ${key} in a strategy section`); + } + updated = true; + return { + ...section, + [key]: items, + }; + }); + return { + ...view, + sections: newSections, + }; + }); + + if (!updated) { + throw new Error(`Can not update ${key} in a non-existing view/section`); + } + return { + ...config, + views: newViews, + }; +}; + +export const findLovelaceItems = ( + key: T, + config: LovelaceConfig, + path: LovelaceContainerPath +): LovelaceItemKeys[T] | undefined => { + const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path); + + const view = config.views[viewIndex]; + + if (!view) { + throw new Error("View does not exist"); + } + if (isStrategyView(view)) { + throw new Error("Can not find cards in a strategy view"); + } + if (sectionIndex === undefined) { + return view[key] as LovelaceItemKeys[T] | undefined; + } + + const section = view.sections?.[sectionIndex]; + + if (!section) { + throw new Error("Section does not exist"); + } + if (isStrategySection(section)) { + throw new Error("Can not find cards in a strategy section"); + } + if (key === "cards") { + return section[key as "cards"] as LovelaceItemKeys[T] | undefined; + } + throw new Error(`${key} is not supported in section`); +}; + + +// **************************************************************************************************** +// HA view.ts +// **************************************************************************************************** +export interface ShowViewConfig { + user?: string; +} + +interface LovelaceViewBackgroundConfig { + image?: string; +} + +export interface LovelaceBaseViewConfig { + index?: number; + title?: string; + path?: string; + icon?: string; + theme?: string; + panel?: boolean; + background?: string | LovelaceViewBackgroundConfig; + visible?: boolean | ShowViewConfig[]; + subview?: boolean; + back_path?: string; + // Only used for section view, it should move to a section view config type when the views will have dedicated editor. + max_columns?: number; + dense_section_placement?: boolean; +} + +export interface LovelaceViewConfig extends LovelaceBaseViewConfig { + type?: string; + badges?: (string | Partial)[]; // Badge can be just an entity_id or without type + cards?: LovelaceCardConfig[]; + sections?: LovelaceSectionRawConfig[]; +} + +export interface LovelaceStrategyViewConfig extends LovelaceBaseViewConfig { + strategy: LovelaceStrategyConfig; +} + +export type LovelaceViewRawConfig = + | LovelaceViewConfig + | LovelaceStrategyViewConfig; + +export function isStrategyView( + view: LovelaceViewRawConfig +): view is LovelaceStrategyViewConfig { + return "strategy" in view; +} + + +// **************************************************************************************************** +// HA section.ts exports. +// **************************************************************************************************** +export interface LovelaceBaseSectionConfig { + visibility?: Condition[]; + column_span?: number; + row_span?: number; + /** + * @deprecated Use heading card instead. + */ + title?: string; +} + +export interface LovelaceSectionConfig extends LovelaceBaseSectionConfig { + type?: string; + cards?: LovelaceCardConfig[]; +} + +export interface LovelaceStrategySectionConfig + extends LovelaceBaseSectionConfig { + strategy: LovelaceStrategyConfig; +} + +export type LovelaceSectionRawConfig = + | LovelaceSectionConfig + | LovelaceStrategySectionConfig; + +export function isStrategySection( + section: LovelaceSectionRawConfig +): section is LovelaceStrategySectionConfig { + return "strategy" in section; +} + + +// **************************************************************************************************** +// HA strategy.ts +// **************************************************************************************************** +export interface LovelaceStrategyConfig { + type: string; + [key: string]: any; +} + + +// **************************************************************************************************** +// HA types.ts +// **************************************************************************************************** +export interface LovelaceDashboardBaseConfig { } + +export interface LovelaceConfig extends LovelaceDashboardBaseConfig { + background?: string; + views: LovelaceViewRawConfig[]; +} + +export interface LovelaceDashboardStrategyConfig + extends LovelaceDashboardBaseConfig { + strategy: LovelaceStrategyConfig; +} + +export interface LegacyLovelaceConfig extends LovelaceConfig { + resources?: LovelaceResource[]; +} + +export type LovelaceRawConfig = + | LovelaceConfig + | LovelaceDashboardStrategyConfig; + +export function isStrategyDashboard( + config: LovelaceRawConfig +): config is LovelaceDashboardStrategyConfig { + return "strategy" in config; +} + +export const fetchConfig = ( + conn: Connection, + urlPath: string | null, + force: boolean +): Promise => + conn.sendMessagePromise({ + type: "lovelace/config", + url_path: urlPath, + force, + }); + +export const saveConfig = ( + hass: HomeAssistant, + urlPath: string | null, + config: LovelaceRawConfig +): Promise => + hass.callWS({ + type: "lovelace/config/save", + url_path: urlPath, + config, + }); + +export const deleteConfig = ( + hass: HomeAssistant, + urlPath: string | null +): Promise => + hass.callWS({ + type: "lovelace/config/delete", + url_path: urlPath, + }); + + +// **************************************************************************************************** +// HA card.ts +// **************************************************************************************************** +export interface LovelaceCardConfig { + index?: number; + view_index?: number; + view_layout?: any; + layout_options?: LovelaceLayoutOptions; + type: string; + [key: string]: any; + visibility?: Condition[]; +} + + +// **************************************************************************************************** +// HA types.ts +// **************************************************************************************************** +export type LovelaceLayoutOptions = { + grid_columns?: number | "full"; + grid_rows?: number | "auto"; + grid_max_columns?: number; + grid_min_columns?: number; + grid_min_rows?: number; + grid_max_rows?: number; +}; + + +// **************************************************************************************************** +// HA badge.ts +// **************************************************************************************************** +export interface LovelaceBadgeConfig { + type: string; + [key: string]: any; + visibility?: Condition[]; +} + +export const ensureBadgeConfig = ( + config: Partial | string +): LovelaceBadgeConfig => { + if (typeof config === "string") { + return { + type: "entity", + entity: config, + show_name: true, + }; + } + if ("type" in config && config.type) { + return config as LovelaceBadgeConfig; + } + return { + type: "entity", + ...config, + }; +}; + + +// **************************************************************************************************** +// HA validate-condition.ts +// **************************************************************************************************** +export type Condition = + | NumericStateCondition + | StateCondition + | ScreenCondition + | UserCondition + | OrCondition + | AndCondition; + +// Legacy conditional card condition +export interface LegacyCondition { + entity?: string; + state?: string | string[]; + state_not?: string | string[]; +} + +interface BaseCondition { + condition: string; +} + +export interface NumericStateCondition extends BaseCondition { + condition: "numeric_state"; + entity?: string; + below?: string | number; + above?: string | number; +} + +export interface StateCondition extends BaseCondition { + condition: "state"; + entity?: string; + state?: string | string[]; + state_not?: string | string[]; +} + +export interface ScreenCondition extends BaseCondition { + condition: "screen"; + media_query?: string; +} + +export interface UserCondition extends BaseCondition { + condition: "user"; + users?: string[]; +} + +export interface OrCondition extends BaseCondition { + condition: "or"; + conditions?: Condition[]; +} + +export interface AndCondition extends BaseCondition { + condition: "and"; + conditions?: Condition[]; +} + + +// **************************************************************************************************** +// HA resource.ts +// **************************************************************************************************** +export type LovelaceResource = { + id: string; + type: "css" | "js" | "module" | "html"; + url: string; +}; + +export type LovelaceResourcesMutableParams = { + res_type: LovelaceResource["type"]; + url: string; +}; + +export const fetchResources = (conn: Connection): Promise => + conn.sendMessagePromise({ + type: "lovelace/resources", + }); + +export const createResource = ( + hass: HomeAssistant, + values: LovelaceResourcesMutableParams +) => + hass.callWS({ + type: "lovelace/resources/create", + ...values, + }); + +export const updateResource = ( + hass: HomeAssistant, + id: string, + updates: Partial +) => + hass.callWS({ + type: "lovelace/resources/update", + resource_id: id, + ...updates, + }); + +export const deleteResource = (hass: HomeAssistant, id: string) => + hass.callWS({ + type: "lovelace/resources/delete", + resource_id: id, +}); diff --git a/src/utils/media-browser-utils.ts b/src/utils/media-browser-utils.ts index 7340a2c..19e27d1 100644 --- a/src/utils/media-browser-utils.ts +++ b/src/utils/media-browser-utils.ts @@ -18,6 +18,8 @@ import { IShowSimplified } from '../types/spotifyplus/show-simplified'; import { ISpotifyConnectDevice } from '../types/spotifyplus/spotify-connect-device'; import { ITrackSimplified } from '../types/spotifyplus/track-simplified'; import { IUserPreset } from '../types/spotifyplus/user-preset'; +import { SearchMediaTypes } from '../types/search-media-types'; +import { ICategory } from '../types/spotifyplus/category'; const DEFAULT_MEDIA_IMAGEURL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAZAAAAGQCAYAAACAvzbMAAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw1AUhU9TS0UqDnYQcchQnexiRXQrVSyChdJWaNXB5KV/0KQhSXFxFFwLDv4sVh1cnHV1cBUEwR8QZwcnRRcp8b6k0CLGC4/3cd49h/fuA4RWjalmXxxQNcvIJBNivrAqBl8RgA8hxDAnMVNPZRdz8Kyve+qluovyLO++P2tQKZoM8InEcaYbFvEG8cympXPeJw6ziqQQnxNPGnRB4keuyy6/cS47LPDMsJHLzBOHicVyD8s9zCqGSjxNHFFUjfKFvMsK5y3Oaq3BOvfkLwwVtZUs12mNIYklpJCGCBkNVFGDhSjtGikmMnSe8PCPOv40uWRyVcHIsYA6VEiOH/wPfs/WLMWm3KRQAgi82PbHOBDcBdpN2/4+tu32CeB/Bq60rr/eAmY/SW92tcgRMLQNXFx3NXkPuNwBRp50yZAcyU9LKJWA9zP6pgIwfAsMrLlz65zj9AHI0ayWb4CDQ2CiTNnrHu/u753bvz2d+f0A+AZy3KgprtwAAAAGYktHRAD/AP8A/6C9p5MAAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfoBQEMNhNCJ/KVAAACg0lEQVR42u3BgQAAAADDoPlTX+EAVQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwG/GFwABsN92WwAAAABJRU5ErkJggg=='; @@ -131,11 +133,12 @@ function hasItemsWithImage(items: any[]) { * * @items Collection of items to display in the media browser. * @config CardConfig object that contains card configuration details. - * @section Current section that is active. + * @mediaItemType Type of media items displayed (a Section value). + * @searchMediaType Current search media type, if section is SEARCH_MEDIA. * @store Common application storage area. * @returns The collection of items, with each item containing IMediaListItem arguments that will be used by the media browser. */ -export function buildMediaBrowserItems(items: any, config: CardConfig, section: Section, store: Store) { +export function buildMediaBrowserItems(items: any, config: CardConfig, mediaItemType: Section, searchMediaType: SearchMediaTypes | null, store: Store) { // do ANY of the items have images? returns true if so, otherwise false. const itemsWithImage = hasItemsWithImage(items); @@ -159,18 +162,25 @@ export function buildMediaBrowserItems(items: any, config: CardConfig, section: }; // modify subtitle value based on selected section type. - if (section == Section.ALBUM_FAVORITES) { + if (mediaItemType == Section.ALBUM_FAVORITES) { const itemInfo = (item as IAlbumSimplified); if ((itemInfo.artists) && (itemInfo.artists.length > 0)) { - mbi_info.subtitle = itemInfo.artists[0]?.name || (itemInfo.total_tracks || 0 + " tracks") || item.type; + if (searchMediaType == SearchMediaTypes.ARTIST_ALBUMS) { + mbi_info.subtitle = itemInfo.release_date || itemInfo.artists[0]?.name || (itemInfo.total_tracks || 0 + " tracks") || item.type; + } else { + mbi_info.subtitle = itemInfo.artists[0]?.name || (itemInfo.total_tracks || 0 + " tracks") || item.type; + } } - } else if (section == Section.ARTIST_FAVORITES) { + } else if (mediaItemType == Section.ARTIST_FAVORITES) { const itemInfo = (item as IArtist); mbi_info.subtitle = ((itemInfo?.followers?.total || 0) + " followers") || item.type; - } else if (section == Section.AUDIOBOOK_FAVORITES) { + } else if (mediaItemType == Section.AUDIOBOOK_FAVORITES) { const itemInfo = (item as IAudiobookSimplified); mbi_info.subtitle = GetAudiobookAuthors(itemInfo, ", ") || item.type; - } else if (section == Section.DEVICES) { + } else if (mediaItemType == Section.CATEGORYS) { + const itemInfo = (item as ICategory); + mbi_info.subtitle = itemInfo.type; + } else if (mediaItemType == Section.DEVICES) { // for device item, the object uses Camel-case names, so we have to use "Name" instead of "name". // we will also show the device brand and model names as the subtitle. // we will also indicate which device is active. @@ -178,26 +188,30 @@ export function buildMediaBrowserItems(items: any, config: CardConfig, section: mbi_info.title = device.Name; mbi_info.subtitle = (device.DeviceInfo.BrandDisplayName || "unknown") + ", " + (device.DeviceInfo.ModelDisplayName || "unknown"); mbi_info.is_active = (item.Name == store.player.attributes.source); - } else if (section == Section.EPISODE_FAVORITES) { - // spotify search episode returns an IEpisodeSimplified, so show property will by null. + } else if (mediaItemType == Section.EPISODE_FAVORITES) { + // spotify search episode returns an IEpisodeSimplified, so show property will be null. // for search results, use release date for subtitle. // for favorite results, use the show name for subtitle. const itemInfo = (item as IEpisode); mbi_info.subtitle = itemInfo.show?.name || itemInfo.release_date || ""; - } else if (section == Section.PLAYLIST_FAVORITES) { + } else if (mediaItemType == Section.PLAYLIST_FAVORITES) { const itemInfo = (item as IPlaylistSimplified); mbi_info.subtitle = (itemInfo.tracks?.total || 0) + " tracks"; - } else if (section == Section.SHOW_FAVORITES) { + } else if (mediaItemType == Section.RECENTS) { + // nothing to do here - already set. + } else if (mediaItemType == Section.SHOW_FAVORITES) { const itemInfo = (item as IShowSimplified); mbi_info.subtitle = (itemInfo.total_episodes || 0) + " episodes"; - } else if (section == Section.TRACK_FAVORITES) { + } else if (mediaItemType == Section.TRACK_FAVORITES) { const itemInfo = (item as ITrackSimplified); if ((itemInfo.artists) && (itemInfo.artists.length > 0)) { mbi_info.subtitle = itemInfo.artists[0].name || item.type; } - } else if (section == Section.USERPRESETS) { + } else if (mediaItemType == Section.USERPRESETS) { const itemInfo = (item as IUserPreset); mbi_info.subtitle = itemInfo.subtitle || item.uri; + } else { + console.log("%cmedia-browser-utils - unknown mediaItemType = %s; mbi_info not set!", "color:red", JSON.stringify(mediaItemType)); } //console.log("%c buildMediaBrowserItems - media browser item:\n%s", @@ -382,10 +396,10 @@ export function formatConfigInfo( /** * Style definition used to style a media browser item background image. */ -export function styleMediaBrowserItemBackgroundImage(thumbnail: string, index: number, section: Section) { +export function styleMediaBrowserItemBackgroundImage(thumbnail: string, index: number, mediaItemType: Section) { let bgSize = '100%'; - if (section == Section.DEVICES) { + if (mediaItemType == Section.DEVICES) { bgSize = '50%'; } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index ff907c9..b7caf7a 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -55,6 +55,38 @@ export function unescapeHtml(escapedHtml: string): string { } +/** + * Return POSIX utc timestamp (e.g. the number of seconds since the utc epoch date). + * + * This is the equivalent of the Python `datetime.utcnow().timestamp()` function. + * + * @returns number of milliseconds between current UTC time and midnight of January 1, 1970. + */ +export function getUtcNowTimestamp(): number { + + const tmLoc = new Date(); + const tmDiffMS = tmLoc.getTime() + (tmLoc.getTimezoneOffset() * 60000); // offset is in minutes; convert it to milliseconds. + return (tmDiffMS / 1000); // convert milliseconds to seconds. + +} + + +/** + * Converts a UTC datetime object to local datetime object. + * + * @param date A UTC datetime object to convert. + * @returns A local datetime object. + */ +export function convertUTCDateToLocalDate(date): Date { + + const newDate = new Date(date.getTime() + date.getTimezoneOffset() * 60 * 1000); + const offset = date.getTimezoneOffset() / 60; + const hours = date.getHours(); + newDate.setHours(hours - offset); + return newDate; +} + + /** * Formats an epoch date to a date locale string. * @@ -72,8 +104,10 @@ export function formatDateEpochSecondsToLocaleString(epochSeconds: number | unde // convert epoch number of seconds to epoch number of milliseconds (for JavaScript Date function). const epochMS = (epochSeconds || 0) * 1000; const epochMSDate = new Date(epochMS); - const localeDate = epochMSDate.toLocaleString(); - return localeDate + const localeDate = convertUTCDateToLocalDate(epochMSDate); + const localeDateString = localeDate.toLocaleString(); + //const localeDateString = epochMSDate.toLocaleString(); + return localeDateString } @@ -132,6 +166,8 @@ export function getSectionForConfigArea(configArea: ConfigArea) { section = Section.ARTIST_FAVORITES; } else if (configArea == ConfigArea.AUDIOBOOK_FAVORITES) { section = Section.AUDIOBOOK_FAVORITES; + } else if (configArea == ConfigArea.CATEGORY_BROWSER) { + section = Section.CATEGORYS; } else if (configArea == ConfigArea.DEVICE_BROWSER) { section = Section.DEVICES; } else if (configArea == ConfigArea.EPISODE_FAVORITES) { @@ -173,6 +209,8 @@ export function getConfigAreaForSection(section: Section) { configArea = ConfigArea.ARTIST_FAVORITES; } else if (section == Section.AUDIOBOOK_FAVORITES) { configArea = ConfigArea.AUDIOBOOK_FAVORITES; + } else if (section == Section.CATEGORYS) { + configArea = ConfigArea.CATEGORY_BROWSER; } else if (section == Section.DEVICES) { configArea = ConfigArea.DEVICE_BROWSER; } else if (section == Section.EPISODE_FAVORITES) { @@ -497,3 +535,23 @@ export function copyToClipboard(ev): boolean { window.status = "text copied to clipboard"; return result; } + + +//export const getLovelace = () => { +// // eslint-disable-next-line @typescript-eslint/no-explicit-any +// let root: any = document.querySelector('home-assistant'); +// root = root && root.shadowRoot; +// root = root && root.querySelector('home-assistant-main'); +// root = root && root.shadowRoot; +// root = root && root.querySelector('app-drawer-layout partial-panel-resolver, ha-drawer partial-panel-resolver'); +// root = (root && root.shadowRoot) || root; +// root = root && root.querySelector('ha-panel-lovelace'); +// root = root && root.shadowRoot; +// root = root && root.querySelector('hui-root'); +// if (root) { +// const ll = root.lovelace; +// ll.current_view = root.___curView; +// return ll; +// } +// return null; +//} From 90186fdd85ff28c861c5b919e68b634a7c6b6a84 Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Wed, 20 Nov 2024 14:18:19 -0600 Subject: [PATCH 03/17] [ 1.0.13 ] * This release requires the SpotifyPlus v1.0.66+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. * Added "Copy Preset Info to Clipboard" action for track and artist in the player track details action menu. This will create a user-preset configuration entry for the selected media and copy it to the clipboard; the entry can then be pasted into the configuration editor under the `userPresets:` key, which will create a user preset for the media item. * Added "Copy Preset Info to Clipboard" action for track and artist in the favorites track details action menu. This will create a user-preset configuration entry for the selected media and copy it to the clipboard; the entry can then be pasted into the configuration editor under the `userPresets:` key, which will create a user preset for the media item. * Added "Show Album Tracks" action for all album action menus. This will display all tracks on the album in the search browser. * Added "Connect / Login to this device" action menu item to Spotify Connect device browser action menu. This will add the device to the Spotify Connect player device list. * Added "Disconnect / Logout from this device" action menu item to Spotify Connect device browser action menu. This will remove the device from the Spotify Connect player device list. * Fixed a bug in userpreset details display that was causing an error alert of "MediaItem not set in updateActions" when a userpreset with type "recommendations" was selected. --- CHANGELOG.md | 12 +- README.md | 3 +- src/components/album-actions.ts | 33 ++- src/components/artist-actions.ts | 10 +- src/components/audiobook-actions.ts | 10 +- src/components/device-actions.ts | 316 ++++++++++++++++++++++-- src/components/episode-actions.ts | 12 +- src/components/media-browser-base.ts | 217 +++++++++++++++- src/components/media-browser-icons.ts | 23 +- src/components/media-browser-list.ts | 29 ++- src/components/player-body-audiobook.ts | 10 +- src/components/player-body-queue.ts | 6 +- src/components/player-body-show.ts | 10 +- src/components/player-body-track.ts | 44 +++- src/components/player-controls.ts | 2 +- src/components/player-progress.ts | 2 +- src/components/player-volume.ts | 6 +- src/components/playlist-actions.ts | 106 +------- src/components/show-actions.ts | 10 +- src/components/track-actions.ts | 44 +++- src/components/userpreset-actions.ts | 4 +- src/constants.ts | 2 +- src/events/search-media.ts | 11 +- src/sections/device-browser.ts | 3 + src/sections/search-media-browser.ts | 78 +++++- src/sections/userpreset-browser.ts | 31 ++- src/services/spotifyplus-service.ts | 218 ++++++++++++++++ src/styles/shared-styles-fav-browser.js | 2 + src/types/search-media-types.ts | 1 + src/types/spotifyplus/user-preset.ts | 146 ++++++++++- src/utils/config-util.ts | 19 ++ src/utils/media-browser-utils.ts | 205 ++------------- src/utils/utils.ts | 24 +- 33 files changed, 1202 insertions(+), 447 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a7480f..eca5f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,19 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.13 ] - 2024/11/20 + + * This release requires the SpotifyPlus v1.0.66+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. + * Added "Copy Preset Info to Clipboard" action for track and artist in the player track details action menu. This will create a user-preset configuration entry for the selected media and copy it to the clipboard; the entry can then be pasted into the configuration editor under the `userPresets:` key, which will create a user preset for the media item. + * Added "Copy Preset Info to Clipboard" action for track and artist in the favorites track details action menu. This will create a user-preset configuration entry for the selected media and copy it to the clipboard; the entry can then be pasted into the configuration editor under the `userPresets:` key, which will create a user preset for the media item. + * Added "Show Album Tracks" action for all album action menus. This will display all tracks on the album in the search browser. + * Added "Connect / Login to this device" action menu item to Spotify Connect device browser action menu. This will add the device to the Spotify Connect player device list. + * Added "Disconnect / Logout from this device" action menu item to Spotify Connect device browser action menu. This will remove the device from the Spotify Connect player device list. + * Fixed a bug in userpreset details display that was causing an error alert of "MediaItem not set in updateActions" when a userpreset with type "recommendations" was selected. + ###### [ 1.0.12 ] - 2024/11/15 - * This release requires the SpotifyPlus v1.0.65 release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. + * This release requires the SpotifyPlus v1.0.65+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. * Added category browser: browse Spotify playlists by categories; existing card configurations have to enable the section in the general configuration settings. * Added dynamic track recommendation capability to user-defined presets. Simply put, you define a preset with the parameters of what you want to play and Spotify searches its media catalog for tracks that match. The matching tracks are then added to a play queue and played in random order. The matching tracks will change over time, as Spotify adds new content to its media catalog. * Added action for all playable media types: Copy Preset Info to Clipboard. This will create a user-preset configuration entry for the selected media and copy it to the clipboard; the entry can then be pasted into the configuration editor under the `userPresets:` key, which will create a user preset for the media item. diff --git a/README.md b/README.md index 751ec32..e3b41eb 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,11 @@ Extended support for the Spotify Service for use in Home Assistant. ## Features * Spotify Media player interface with customizable controls and information display. -* Spotify real-time search for all media types. +* Search Spotify catalog for all media types (tracks, playlists, albums, artists, shows, audiobooks, episodes, categories, etc). * Display / Select your Spotify favorites: Albums, Artists, Audiobooks, Episodes, Shows, Tracks. * Display / Select Spotify Connect device outputs. * User-defined media item presets (both file and code edited supported). +* User-defined recommendation presets; play dynamically generated content based on user-defined criteria (e.g. energy, loudness, danceability, etc). * Favorite status / add / remove support for all media types. * View Player Queue information. * Card Configuration Editor User-Interface for changing options. diff --git a/src/components/album-actions.ts b/src/components/album-actions.ts index 7895029..3e2dd27 100644 --- a/src/components/album-actions.ts +++ b/src/components/album-actions.ts @@ -45,6 +45,8 @@ enum Actions { AlbumTrackQueueAdd = "AlbumTrackQueueAdd", AlbumTracksUpdate = "AlbumTracksUpdate", AlbumSearchRadio = "AlbumSearchRadio", + AlbumShowTracks = "AlbumShowTracks", + ArtistCopyPresetToClipboard = "ArtistCopyPresetToClipboard", ArtistCopyUriToClipboard = "ArtistCopyUriToClipboard", ArtistFavoriteAdd = "ArtistFavoriteAdd", ArtistFavoriteRemove = "ArtistFavoriteRemove", @@ -88,7 +90,7 @@ class AlbumActions extends FavActionsBase { * 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 { + protected override render(): TemplateResult | void { // invoke base class method. super.render(); @@ -167,6 +169,10 @@ class AlbumActions extends FavActionsBase { + this.onClickAction(Actions.AlbumShowTracks)} hide=${this.hideSearchType(SearchMediaTypes.TRACKS)}> + +
Show Album Tracks
+
this.onClickAction(Actions.AlbumSearchRadio)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}>
Search for Album Radio
@@ -231,6 +237,10 @@ class AlbumActions extends FavActionsBase {
Copy Artist URI to Clipboard
+ this.onClickAction(Actions.ArtistCopyPresetToClipboard)}> + +
Copy Artist Preset Info to Clipboard
+
`; @@ -377,6 +387,17 @@ class AlbumActions extends FavActionsBase { this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.mediaItem.name + RADIO_SEARCH_KEY + this.mediaItem.artists[0].name)); return true; + } else if (action == Actions.AlbumShowTracks) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ALBUM_TRACKS, this.mediaItem.name + "; " + this.mediaItem.artists[0].name, this.mediaItem.name, this.mediaItem.uri, null, this.mediaItem)); + return true; + + } else if (action == Actions.ArtistCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.mediaItem.artists[0])); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.ArtistCopyUriToClipboard) { copyTextToClipboard(this.mediaItem.artists[0].uri); @@ -469,7 +490,7 @@ class AlbumActions extends FavActionsBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Action failed: \n" + (error as Error).message); + this.alertErrorSet("Action failed: " + (error as Error).message); return true; } @@ -521,7 +542,7 @@ class AlbumActions extends FavActionsBase { // clear results, and reject the promise. this.albumTracks = undefined; - this.alertErrorSet("Get Album Tracks failed: \n" + (error as Error).message); + this.alertErrorSet("Get Album Tracks failed: " + (error as Error).message); reject(error); }) @@ -550,7 +571,7 @@ class AlbumActions extends FavActionsBase { // clear results, and reject the promise. this.isAlbumFavorite = undefined; - this.alertErrorSet("Check Album Favorite failed: \n" + (error as Error).message); + this.alertErrorSet("Check Album Favorite failed: " + (error as Error).message); reject(error); }) @@ -579,7 +600,7 @@ class AlbumActions extends FavActionsBase { // clear results, and reject the promise. this.isArtistFavorite = undefined; - this.alertErrorSet("Check Artist Following failed: \n" + (error as Error).message); + this.alertErrorSet("Check Artist Following failed: " + (error as Error).message); reject(error); }) @@ -608,7 +629,7 @@ class AlbumActions extends FavActionsBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Album actions refresh failed: \n" + (error as Error).message); + this.alertErrorSet("Album actions refresh failed: " + (error as Error).message); return true; } diff --git a/src/components/artist-actions.ts b/src/components/artist-actions.ts index 920e2de..3740715 100644 --- a/src/components/artist-actions.ts +++ b/src/components/artist-actions.ts @@ -89,7 +89,7 @@ class ArtistActions extends FavActionsBase { * 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 { + protected override render(): TemplateResult | void { // invoke base class method. super.render(); @@ -400,7 +400,7 @@ class ArtistActions extends FavActionsBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Action failed: \n" + (error as Error).message); + this.alertErrorSet("Action failed: " + (error as Error).message); return true; } @@ -459,7 +459,7 @@ class ArtistActions extends FavActionsBase { // clear results, and reject the promise. //this.artistInfo = undefined; - //this.alertErrorSet("Get Artist Info failed: \n" + (error as Error).message); + //this.alertErrorSet("Get Artist Info failed: " + (error as Error).message); //reject(error); }) @@ -488,7 +488,7 @@ class ArtistActions extends FavActionsBase { // clear results, and reject the promise. this.isArtistFavorite = undefined; - this.alertErrorSet("Check Artist Favorites failed: \n" + (error as Error).message); + this.alertErrorSet("Check Artist Favorites failed: " + (error as Error).message); reject(error); }) @@ -517,7 +517,7 @@ class ArtistActions extends FavActionsBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Artist actions refresh failed: \n" + (error as Error).message); + this.alertErrorSet("Artist actions refresh failed: " + (error as Error).message); return true; } diff --git a/src/components/audiobook-actions.ts b/src/components/audiobook-actions.ts index 507f0c6..e32d2c8 100644 --- a/src/components/audiobook-actions.ts +++ b/src/components/audiobook-actions.ts @@ -72,7 +72,7 @@ class AudiobookActions extends FavActionsBase { * 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 { + protected override render(): TemplateResult | void { // invoke base class method. super.render(); @@ -313,7 +313,7 @@ class AudiobookActions extends FavActionsBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Action failed: \n" + (error as Error).message); + this.alertErrorSet("Action failed: " + (error as Error).message); return true; } @@ -366,7 +366,7 @@ class AudiobookActions extends FavActionsBase { // clear results, and reject the promise. this.audiobookChapters = undefined; - this.alertErrorSet("Get Audiobook Chapters failed: \n" + (error as Error).message); + this.alertErrorSet("Get Audiobook Chapters failed: " + (error as Error).message); reject(error); }) @@ -395,7 +395,7 @@ class AudiobookActions extends FavActionsBase { // clear results, and reject the promise. this.isAudiobookFavorite = undefined; - this.alertErrorSet("Check Audiobook Favorites failed: \n" + (error as Error).message); + this.alertErrorSet("Check Audiobook Favorites failed: " + (error as Error).message); reject(error); }) @@ -424,7 +424,7 @@ class AudiobookActions extends FavActionsBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Audiobook actions refresh failed: \n" + (error as Error).message); + this.alertErrorSet("Audiobook actions refresh failed: " + (error as Error).message); return true; } diff --git a/src/components/device-actions.ts b/src/components/device-actions.ts index 05c89c7..0902c41 100644 --- a/src/components/device-actions.ts +++ b/src/components/device-actions.ts @@ -1,21 +1,40 @@ // lovelace card imports. import { css, html, TemplateResult } from 'lit'; -import { property } from 'lit/decorators.js'; +import { property, state } from 'lit/decorators.js'; +import { + mdiDotsHorizontal, + mdiLanConnect, + mdiLanDisconnect, +} from '@mdi/js'; // our imports. -import { sharedStylesGrid } from '../styles/shared-styles-grid.js'; -import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.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 { copyToClipboard } from '../utils/utils'; import { ISpotifyConnectDevice } from '../types/spotifyplus/spotify-connect-device'; +/** + * Device actions. + */ +enum Actions { + DeviceDisconnect = "DeviceDisconnect", + DeviceConnect = "DeviceConnect", + DeviceGetInfo = "DeviceGetInfo", +} + class DeviceActions extends FavActionsBase { // public state properties. @property({ attribute: false }) mediaItem!: ISpotifyConnectDevice; + // private state properties. + @state() private deviceInfo?: ISpotifyConnectDevice; + /** * Initializes a new instance of the class. @@ -33,7 +52,36 @@ class DeviceActions extends FavActionsBase { * 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 { + protected override render(): TemplateResult | void { + + // invoke base class method. + super.render(); + + // if device info not set, then use media item that was passed from fav-browser + // for the initial display. + if (!this.deviceInfo) { + this.deviceInfo = this.mediaItem; + } + + // set Spotify Connect device list status indicator. + const deviceListClass = (this.deviceInfo?.DeviceInfo.IsInDeviceList) ? "device-list-in" : "device-list-out"; + + // define dropdown menu actions - artist. + const actionsDeviceHtml = html` + + + + + this.onClickAction(Actions.DeviceConnect)}> + +
Connect / Login to this device
+
+ this.onClickAction(Actions.DeviceDisconnect)}> + +
Disconnect / Logout from this device
+
+
+ `; // render html. return html` @@ -41,57 +89,67 @@ class DeviceActions extends FavActionsBase { ${this.alertError ? html`${this.alertError}` : ""} ${this.alertInfo ? html`${this.alertInfo}` : ""}
-
+
-
${this.mediaItem.Name}
-
${this.mediaItem.DeviceInfo.BrandDisplayName}
-
${this.mediaItem.DeviceInfo.ModelDisplayName}
+
+ ${this.deviceInfo?.Name} + + ${actionsDeviceHtml} + +
+
${this.deviceInfo?.DeviceInfo.BrandDisplayName}
+
${this.deviceInfo?.DeviceInfo.ModelDisplayName}
+ ${(this.deviceInfo?.DeviceInfo.IsBrandSonos) ? html` +
+ Sonos devices will not appear in Spotify Web API device list +
+ ` : ""}
Device ID
-
${this.mediaItem.DeviceInfo.DeviceId}
+
${this.deviceInfo?.DeviceInfo.DeviceId}
Device Name
-
${this.mediaItem.DiscoveryResult.DeviceName}
+
${this.deviceInfo?.DiscoveryResult.DeviceName}
Device Type
-
${this.mediaItem.DeviceInfo.DeviceType}
+
${this.deviceInfo?.DeviceInfo.DeviceType}
Product ID
-
${this.mediaItem.DeviceInfo.ProductId}
+
${this.deviceInfo?.DeviceInfo.ProductId}
Voice Support?
-
${this.mediaItem.DeviceInfo.VoiceSupport}
+
${this.deviceInfo?.DeviceInfo.VoiceSupport}
IP DNS Alias
-
${this.mediaItem.DiscoveryResult.Server}
+
${this.deviceInfo?.DiscoveryResult.Server}
IP Address
-
${this.mediaItem.DiscoveryResult.HostIpAddress}
+
${this.deviceInfo?.DiscoveryResult.HostIpAddress}
Zeroconf IP Port
-
${this.mediaItem.DiscoveryResult.HostIpPort}
+
${this.deviceInfo?.DiscoveryResult.HostIpPort}
Zeroconf CPath
-
${this.mediaItem.DiscoveryResult.SpotifyConnectCPath}
+
${this.deviceInfo?.DiscoveryResult.SpotifyConnectCPath}
Is Dynamic Device?
-
${this.mediaItem.DiscoveryResult.IsDynamicDevice}
+
${this.deviceInfo?.DiscoveryResult.IsDynamicDevice}
Is in Device List?
-
${this.mediaItem.DeviceInfo.IsInDeviceList}
+
${this.deviceInfo?.DeviceInfo.IsInDeviceList}
Auth Token Type
-
${this.mediaItem.DeviceInfo.TokenType}
+
${this.deviceInfo?.DeviceInfo.TokenType}
Client ID
-
${this.mediaItem.DeviceInfo.ClientId}
+
${this.deviceInfo?.DeviceInfo.ClientId}
Library Version
-
${this.mediaItem.DeviceInfo.LibraryVersion}
+
${this.deviceInfo?.DeviceInfo.LibraryVersion}
@@ -106,6 +164,7 @@ class DeviceActions extends FavActionsBase { return [ sharedStylesGrid, sharedStylesMediaInfo, + sharedStylesFavActions, css` .device-actions-container { @@ -120,6 +179,35 @@ class DeviceActions extends FavActionsBase { justify-content: left; } + .device-list-in { + color: limegreen; + font-weight: bold; + } + + .device-list-out { + color: red; + font-weight: bold; + } + + /* reduce image size for device */ + .media-info-content .img { + background-size: contain !important; + background-repeat: no-repeat !important; + background-position: center !important; + max-width: 100px; + min-height: 100px; + border-radius: var(--control-button-border-radius, 10px) !important; + background-size: cover !important; + } + + .padT { + padding-top: 0.2rem; + } + + .padL { + padding-left: 0.2rem; + } + .copy2cb:hover { cursor: copy; } @@ -134,6 +222,190 @@ class DeviceActions extends FavActionsBase { ]; } + + /** + * Handles the `click` event fired when a control icon is clicked. + * + * @param action Action to execute. + * @param args Action arguments. + */ + protected override async onClickAction(action: Actions): Promise { + + // if card is being edited, then don't bother. + if (this.isCardInEditPreview) { + return true; + } + + try { + + // process actions that don't require a progress indicator. + // nothing to process. + + // show progress indicator. + this.progressShow(); + + // call service based on requested action, and refresh affected action component. + if (action == Actions.DeviceConnect) { + + if (this.deviceInfo?.DiscoveryResult.IsDynamicDevice) { + + // if dynamic device, then it cannot be managed. + this.alertInfoSet("Dynamic devices cannot be managed."); + this.progressHide(); + + } else { + + // connect the device. + this.alertInfoSet("Connecting to Spotify Connect device ..."); + await this.spotifyPlusService.ZeroconfDeviceConnect(this.player.id, this.mediaItem, null, null, null, true, true, 1.0); + this.alertInfoSet("Spotify Connect device should be connected."); + this.updateActions(this.player, [Actions.DeviceGetInfo]); + + } + + } else if (action == Actions.DeviceDisconnect) { + + if (this.mediaItem.DiscoveryResult.IsDynamicDevice) { + + // if dynamic device, then it cannot be managed. + this.alertInfoSet("Dynamic devices cannot be managed."); + this.progressHide(); + + } else if (this.mediaItem.DeviceInfo.BrandDisplayName == 'librespot') { + + // librespot does not support Spotify Connect disconnect. + this.alertInfoSet("Librespot devices do not support Spotify Connect disconnect."); + this.progressHide(); + + } else { + + // disconnect the device. + this.alertInfoSet("Disconnecting from Spotify Connect device ..."); + await this.spotifyPlusService.ZeroconfDeviceDisconnect(this.player.id, this.mediaItem, 1.0); + this.alertInfoSet("Spotify Connect device was disconnected."); + this.updateActions(this.player, [Actions.DeviceGetInfo]); + + } + + } 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: " + (error as Error).message); + return true; + + } + finally { + } + + } + + + /** + * Updates body actions. + * + * @param player Media player instance that will process the update. + * @param updateActions List of actions that need to be updated, or an empty list to update DEFAULT actions. + * @returns True if actions update should continue after calling base class method; otherwise, False to abort actions update. + */ + protected override updateActions( + player: MediaPlayer, + updateActions: any[], + ): boolean { + + // invoke base class method; if it returns false, then we should not update actions. + if (!super.updateActions(player, updateActions)) { + return false; + } + + try { + + const promiseRequests = new Array>(); + + // was this action chosen to be updated? + if (updateActions.indexOf(Actions.DeviceGetInfo) != -1) { + + // create promise for this action. + const promiseDeviceGetInfo = new Promise((resolve, reject) => { + + // update status. + this.alertInfo = "Retrieving Spotify Connect status for \"" + this.mediaItem.Name + "\" ..."; + + // set service parameters. + const refresh_device_list = true; + const activate_device = false; + + // get Spotify Connect device info. + this.spotifyPlusService.GetSpotifyConnectDevice(player.id, this.mediaItem.Id, null, null, refresh_device_list, activate_device) + .then(device => { + + // clear certain info messsages if they are temporary. + if (this.alertInfo?.startsWith("Retrieving ")) { + this.alertInfoClear(); + } + + // stash the result into state, and resolve the promise. + // we will also update the mediaItem with the latest info, in case it changed. + this.deviceInfo = device; + if (device) { + //console.log("updateActions - updating mediaItem:\n%s", JSON.stringify(device, null, 2)); + this.mediaItem = device; + //this.mediaItem.DeviceInfo = device.DeviceInfo; + //this.mediaItem.DiscoveryResult = device.DiscoveryResult; + } + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.deviceInfo = undefined; + this.alertErrorSet("Get Spotify Connect Device failed: " + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseDeviceGetInfo); + } + + // show visual progress indicator. + this.progressShow(); + + // execute all promises, and wait for all of them to settle. + // we use `finally` logic so we can clear the progress indicator. + // any exceptions raised should have already been handled in the + // individual promise definitions; nothing else to do at this point. + Promise.allSettled(promiseRequests).finally(() => { + + // clear the progress indicator. + this.progressHide(); + + }); + return true; + + } + catch (error) { + + // clear the progress indicator and set alert error message. + this.progressHide(); + this.alertErrorSet("Update device actions failed: " + (error as Error).message); + return true; + + } + finally { + } + } + } diff --git a/src/components/episode-actions.ts b/src/components/episode-actions.ts index 93c337f..26dc265 100644 --- a/src/components/episode-actions.ts +++ b/src/components/episode-actions.ts @@ -74,7 +74,7 @@ class EpisodeActions extends FavActionsBase { * 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 { + protected override render(): TemplateResult | void { // invoke base class method. super.render(); @@ -356,7 +356,7 @@ class EpisodeActions extends FavActionsBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Action failed: \n" + (error as Error).message); + this.alertErrorSet("Action failed: " + (error as Error).message); return true; } @@ -434,7 +434,7 @@ class EpisodeActions extends FavActionsBase { // clear results, and reject the promise. this.episode = undefined; - this.alertErrorSet("Get Episode call failed: \n" + (error as Error).message); + this.alertErrorSet("Get Episode call failed: " + (error as Error).message); reject(error); }) @@ -463,7 +463,7 @@ class EpisodeActions extends FavActionsBase { // clear results, and reject the promise. this.isShowFavorite = undefined; - this.alertErrorSet("Check Show Favorite failed: \n" + (error as Error).message); + this.alertErrorSet("Check Show Favorite failed: " + (error as Error).message); reject(error); }) @@ -492,7 +492,7 @@ class EpisodeActions extends FavActionsBase { // clear results, and reject the promise. this.isEpisodeFavorite = undefined; - this.alertErrorSet("Check Episode Favorites failed: \n" + (error as Error).message); + this.alertErrorSet("Check Episode Favorites failed: " + (error as Error).message); reject(error); }) @@ -521,7 +521,7 @@ class EpisodeActions extends FavActionsBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Episode actions refresh failed: \n" + (error as Error).message); + this.alertErrorSet("Episode actions refresh failed: " + (error as Error).message); return true; } diff --git a/src/components/media-browser-base.ts b/src/components/media-browser-base.ts index c02bd49..62fae89 100644 --- a/src/components/media-browser-base.ts +++ b/src/components/media-browser-base.ts @@ -1,5 +1,5 @@ // lovelace card imports. -import { css, LitElement, TemplateResult } from 'lit'; +import { css, html, LitElement, TemplateResult } from 'lit'; import { eventOptions, property } from 'lit/decorators.js'; // our imports. @@ -7,17 +7,30 @@ import { Store } from '../model/store'; import { CardConfig } from '../types/card-config'; import { Section } from '../types/section'; import { ITEM_SELECTED, ITEM_SELECTED_WITH_HOLD } from '../constants'; -import { closestElement, customEvent, isTouchDevice } from '../utils/utils'; -import { IMediaBrowserItem } from '../types/media-browser-item'; -import { styleMediaBrowserItemTitle } from '../utils/media-browser-utils'; +import { closestElement, customEvent, formatStringProperCase, isTouchDevice } from '../utils/utils'; +import { getContentItemImageUrl, hasMediaItemImages } from '../utils/media-browser-utils'; import { SearchMediaTypes } from '../types/search-media-types'; +import { IAlbumSimplified } from '../types/spotifyplus/album-simplified'; +import { IArtist } from '../types/spotifyplus/artist'; +import { IAudiobookSimplified, GetAudiobookAuthors } from '../types/spotifyplus/audiobook-simplified'; +import { ICategory } from '../types/spotifyplus/category'; +import { IEpisode } from '../types/spotifyplus/episode'; +import { IMediaBrowserInfo, IMediaBrowserItem } from '../types/media-browser-item'; +import { IPlaylistSimplified } from '../types/spotifyplus/playlist-simplified'; +import { IShowSimplified } from '../types/spotifyplus/show-simplified'; +import { ISpotifyConnectDevice } from '../types/spotifyplus/spotify-connect-device'; +import { ITrackSimplified } from '../types/spotifyplus/track-simplified'; +import { IUserPreset } from '../types/spotifyplus/user-preset'; + +const DEFAULT_MEDIA_IMAGEURL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAZAAAAGQCAYAAACAvzbMAAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw1AUhU9TS0UqDnYQcchQnexiRXQrVSyChdJWaNXB5KV/0KQhSXFxFFwLDv4sVh1cnHV1cBUEwR8QZwcnRRcp8b6k0CLGC4/3cd49h/fuA4RWjalmXxxQNcvIJBNivrAqBl8RgA8hxDAnMVNPZRdz8Kyve+qluovyLO++P2tQKZoM8InEcaYbFvEG8cympXPeJw6ziqQQnxNPGnRB4keuyy6/cS47LPDMsJHLzBOHicVyD8s9zCqGSjxNHFFUjfKFvMsK5y3Oaq3BOvfkLwwVtZUs12mNIYklpJCGCBkNVFGDhSjtGikmMnSe8PCPOv40uWRyVcHIsYA6VEiOH/wPfs/WLMWm3KRQAgi82PbHOBDcBdpN2/4+tu32CeB/Bq60rr/eAmY/SW92tcgRMLQNXFx3NXkPuNwBRp50yZAcyU9LKJWA9zP6pgIwfAsMrLlz65zj9AHI0ayWb4CDQ2CiTNnrHu/u753bvz2d+f0A+AZy3KgprtwAAAAGYktHRAD/AP8A/6C9p5MAAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfoBQEMNhNCJ/KVAAACg0lEQVR42u3BgQAAAADDoPlTX+EAVQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwG/GFwABsN92WwAAAABJRU5ErkJggg=='; export class MediaBrowserBase extends LitElement { // public state properties. @property({ attribute: false }) protected store!: Store; - @property({ attribute: false }) protected items!: IMediaBrowserItem[]; + @property({ attribute: false }) protected items!: any[]; @property({ attribute: false }) protected searchMediaType!: any; protected config!: CardConfig; @@ -28,6 +41,7 @@ export class MediaBrowserBase extends LitElement { protected hideTitle!: boolean; protected hideSubTitle!: boolean; protected isTouchDevice!: boolean; + protected itemsHaveImages!: boolean; protected itemsPerRow!: number; protected mediaItemType!: any; protected listItemClass!: string; @@ -65,6 +79,9 @@ export class MediaBrowserBase extends LitElement { this.itemsPerRow = 2; this.listItemClass = 'button'; + // do ANY of the items have images? returns true if so, otherwise false. + this.itemsHaveImages = hasMediaItemImages(this.items || []); + // assign the mediaItemType based on the section value. // for search, we will convert the SearchMediaType to a mediaItemType. if (this.section != Section.SEARCH_MEDIA) { @@ -85,6 +102,15 @@ export class MediaBrowserBase extends LitElement { this.mediaItemType = Section.SHOW_FAVORITES; } else if (this.searchMediaType == SearchMediaTypes.TRACKS) { this.mediaItemType = Section.TRACK_FAVORITES; + // album-specific search types: + } else if (this.searchMediaType == SearchMediaTypes.ALBUM_TRACKS) { + this.mediaItemType = Section.TRACK_FAVORITES; + //this.mediaItemType = Section.TRACK_FAVORITES; // TODO REMOVEME + //this.itemsPerRow = 1; + //this.hideTitle = false; + //this.hideSubTitle = false; + //this.listItemClass += ' button-track'; + //return; // artist-specific search types: } else if (this.searchMediaType == SearchMediaTypes.ARTIST_ALBUMS) { this.mediaItemType = Section.ALBUM_FAVORITES; @@ -126,7 +152,7 @@ export class MediaBrowserBase extends LitElement { this.hideTitle = this.config.deviceBrowserItemsHideTitle || false; this.hideSubTitle = this.config.deviceBrowserItemsHideSubTitle || false; // for devices, make the source icons half the size of regular list buttons. - this.listItemClass += ' button-source'; + this.listItemClass += ' button-device'; } else if (this.mediaItemType == Section.EPISODE_FAVORITES) { this.itemsPerRow = this.config.episodeFavBrowserItemsPerRow || 4; this.hideTitle = this.config.episodeFavBrowserItemsHideTitle || false; @@ -171,6 +197,23 @@ export class MediaBrowserBase extends LitElement { } +// /** +// * Style definition used to style a media browser item title. +// */ +// export const styleMediaBrowserItemTitle = css` +// .title { +// color: var(--secondary-text-color); +// font-weight: normal; +// padding: 0 0.5rem; +// text-overflow: ellipsis; +// overflow: hidden; +// white-space: nowrap; +// } +//`; + + + + /** * Style definitions used by this card section. * @@ -178,7 +221,6 @@ export class MediaBrowserBase extends LitElement { */ static get styles() { return [ - styleMediaBrowserItemTitle, css` .icons { display: flex; @@ -204,22 +246,31 @@ export class MediaBrowserBase extends LitElement { } .title { - font-size: 0.8rem; position: absolute; - width: 100%; + font-size: 0.8rem; + font-weight: normal; line-height: 160%; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; bottom: 0; background-color: rgba(var(--rgb-card-background-color), 0.733); + color: var(--secondary-text-color); + padding: 0 0.5rem; + white-space: nowrap; } .title-active { color: var(--dark-primary-color) !important; } - .title-source { + .subtitle { font-size: 0.8rem; - width: 100%; + font-weight: normal; line-height: 160%; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; } `, ]; @@ -468,4 +519,148 @@ export class MediaBrowserBase extends LitElement { } + + /** + * Style definition used to style a media browser item background image. + */ + protected styleMediaBrowserItemBackgroundImage(thumbnail: string, index: number) { + + let bgSize = '100%'; + if (this.section == Section.DEVICES) { + bgSize = '50%'; + } + + return html` + + `; + } + + + /** + * Appends IMediaBrowserItem properties to each item in a collection of items + * that are destined to be displayed in the media browser. + * + * @returns The collection of items, with each item containing IMediaListItem arguments that will be used by the media browser. + */ + protected buildMediaBrowserItems() { + + // process all items in the collection. + return (this.items || []).map((item) => { + + //console.log("%c buildMediaBrowserItems - media list item:\n%s", + // "color: yellow;", + // JSON.stringify(item), + //); + + // build media browser info item, that will be merged with the base item. + // get image to use as a thumbnail for the item; + // if no image can be obtained, then use the default. + const mbi_info: IMediaBrowserInfo = { + image_url: getContentItemImageUrl(item, this.config, this.itemsHaveImages, DEFAULT_MEDIA_IMAGEURL), + title: item.name, + subtitle: item.type, + is_active: false, + }; + + // modify subtitle value based on selected section type. + if (this.mediaItemType == Section.ALBUM_FAVORITES) { + const itemInfo = (item as IAlbumSimplified); + if ((itemInfo.artists) && (itemInfo.artists.length > 0)) { + if (this.searchMediaType == SearchMediaTypes.ARTIST_ALBUMS) { + mbi_info.subtitle = itemInfo.release_date || itemInfo.artists[0]?.name || (itemInfo.total_tracks || 0 + " tracks") || item.type; + } else { + mbi_info.subtitle = itemInfo.artists[0]?.name || (itemInfo.total_tracks || 0 + " tracks") || item.type; + } + } + } else if (this.mediaItemType == Section.ARTIST_FAVORITES) { + const itemInfo = (item as IArtist); + mbi_info.subtitle = ((itemInfo?.followers?.total || 0) + " followers") || item.type; + } else if (this.mediaItemType == Section.AUDIOBOOK_FAVORITES) { + const itemInfo = (item as IAudiobookSimplified); + mbi_info.subtitle = GetAudiobookAuthors(itemInfo, ", ") || item.type; + } else if (this.mediaItemType == Section.CATEGORYS) { + const itemInfo = (item as ICategory); + mbi_info.subtitle = itemInfo.type; + } else if (this.mediaItemType == Section.DEVICES) { + // for device item, the object uses Camel-case names, so we have to use "Name" instead of "name". + // we will also show the device brand and model names as the subtitle. + // we will also indicate which device is active. + const device = (item as ISpotifyConnectDevice); + mbi_info.title = device.Name; + mbi_info.subtitle = (device.DeviceInfo.BrandDisplayName || "unknown") + ", " + (device.DeviceInfo.ModelDisplayName || "unknown"); + mbi_info.is_active = (item.Name == this.store.player.attributes.source); + } else if (this.mediaItemType == Section.EPISODE_FAVORITES) { + // spotify search episode returns an IEpisodeSimplified, so show property will be null. + // for search results, use release date for subtitle. + // for favorite results, use the show name for subtitle. + const itemInfo = (item as IEpisode); + mbi_info.subtitle = itemInfo.show?.name || itemInfo.release_date || ""; + } else if (this.mediaItemType == Section.PLAYLIST_FAVORITES) { + const itemInfo = (item as IPlaylistSimplified); + mbi_info.subtitle = (itemInfo.tracks?.total || 0) + " tracks"; + } else if (this.mediaItemType == Section.RECENTS) { + // nothing to do here - already set. + } else if (this.mediaItemType == Section.SHOW_FAVORITES) { + const itemInfo = (item as IShowSimplified); + mbi_info.subtitle = (itemInfo.total_episodes || 0) + " episodes"; + } else if (this.mediaItemType == Section.TRACK_FAVORITES) { + const itemInfo = (item as ITrackSimplified); + //if (this.searchMediaType == SearchMediaTypes.ALBUM_TRACKS) { // TODO REMOVEME + // mbi_info.subtitle = "Track " + itemInfo.track_number; + // if (itemInfo.disc_number > 1) { + // mbi_info.subtitle += ", Disc " + itemInfo.disc_number; + // } + //} + if ((itemInfo.artists) && (itemInfo.artists.length > 0)) { + mbi_info.subtitle = itemInfo.artists[0].name || item.type; + } + } else if (this.mediaItemType == Section.USERPRESETS) { + const itemInfo = (item as IUserPreset); + mbi_info.subtitle = itemInfo.subtitle || item.uri; + } else { + console.log("%cmedia-browser-utils - unknown mediaItemType = %s; mbi_info not set!", "color:red", JSON.stringify(this.mediaItemType)); + } + + //console.log("%c buildMediaBrowserItems - media browser item:\n%s", + // "color: yellow;", + // JSON.stringify({ + // ...item, + // mbi_item: mbi_info, + // }), + //); + + // append media browser item arguments to the item. + return { + ...item, + mbi_item: mbi_info + }; + }); + } + + + protected renderMediaBrowserItem( + item: IMediaBrowserItem, + showTitle: boolean = true, + showSubTitle: boolean = true, + ) { + + let clsActive = '' + if (item.mbi_item.is_active) { + clsActive = ' title-active'; + } + + return html` +
+
+ ${item.mbi_item.title} +
${formatStringProperCase(item.mbi_item.subtitle || '')}
+
+ `; + } + } diff --git a/src/components/media-browser-icons.ts b/src/components/media-browser-icons.ts index 0a91ee9..6b126d9 100644 --- a/src/components/media-browser-icons.ts +++ b/src/components/media-browser-icons.ts @@ -5,12 +5,6 @@ import { css, html, TemplateResult } from 'lit'; import { MediaBrowserBase } from './media-browser-base'; import { ITEM_SELECTED } from '../constants'; import { customEvent } from '../utils/utils'; -import { - buildMediaBrowserItems, - renderMediaBrowserItem, - styleMediaBrowserItemBackgroundImage, - styleMediaBrowserItemTitle -} from '../utils/media-browser-utils'; export class MediaBrowserIcons extends MediaBrowserBase { @@ -39,9 +33,9 @@ export class MediaBrowserIcons extends MediaBrowserBase { // render html. return html`
- ${buildMediaBrowserItems(this.items || [], this.config, this.mediaItemType, this.searchMediaType, this.store).map( + ${this.buildMediaBrowserItems().map( (item, index) => html` - ${styleMediaBrowserItemBackgroundImage(item.mbi_item.image_url, index, this.mediaItemType)} + ${this.styleMediaBrowserItemBackgroundImage(item.mbi_item.image_url, index)} ${(() => { if (this.isTouchDevice) { return (html` @@ -51,7 +45,7 @@ export class MediaBrowserIcons extends MediaBrowserBase { @touchstart=${{handleEvent: () => this.onMediaBrowserItemTouchStart(customEvent(ITEM_SELECTED, item)), passive: true }} @touchend=${() => this.onMediaBrowserItemTouchEnd(customEvent(ITEM_SELECTED, item))} > - ${renderMediaBrowserItem(item, !item.mbi_item.image_url || !this.hideTitle, !this.hideSubTitle)} + ${this.renderMediaBrowserItem(item, !item.mbi_item.image_url || !this.hideTitle, !this.hideSubTitle)} `); } else { @@ -63,7 +57,7 @@ export class MediaBrowserIcons extends MediaBrowserBase { @mousedown=${() => this.onMediaBrowserItemMouseDown()} @mouseup=${() => this.onMediaBrowserItemMouseUp(customEvent(ITEM_SELECTED, item))} > - ${renderMediaBrowserItem(item, !item.mbi_item.image_url || !this.hideTitle, !this.hideSubTitle)} + ${this.renderMediaBrowserItem(item, !item.mbi_item.image_url || !this.hideTitle, !this.hideSubTitle)} `); } @@ -82,7 +76,6 @@ export class MediaBrowserIcons extends MediaBrowserBase { */ static get styles() { return [ - styleMediaBrowserItemTitle, css` .icons { display: flex; @@ -114,9 +107,15 @@ export class MediaBrowserIcons extends MediaBrowserBase { line-height: 160%; bottom: 0; background-color: rgba(var(--rgb-card-background-color), 0.733); + color: var(--secondary-text-color); + font-weight: normal; + padding: 0 0.5rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; } - .title-source { + .subtitle { font-size: 0.8rem; width: 100%; line-height: 160%; diff --git a/src/components/media-browser-list.ts b/src/components/media-browser-list.ts index 6297525..37742d2 100644 --- a/src/components/media-browser-list.ts +++ b/src/components/media-browser-list.ts @@ -7,12 +7,6 @@ import { MediaBrowserBase } from './media-browser-base'; import { Section } from '../types/section'; import { listStyle, ITEM_SELECTED } from '../constants'; import { customEvent } from '../utils/utils'; -import { - buildMediaBrowserItems, - renderMediaBrowserItem, - styleMediaBrowserItemBackgroundImage, - styleMediaBrowserItemTitle -} from '../utils/media-browser-utils'; export class MediaBrowserList extends MediaBrowserBase { @@ -50,9 +44,9 @@ export class MediaBrowserList extends MediaBrowserBase { // render html. return html` - ${buildMediaBrowserItems(this.items || [], this.config, this.mediaItemType, this.searchMediaType, this.store).map((item, index) => { + ${this.buildMediaBrowserItems().map((item, index) => { return html` - ${styleMediaBrowserItemBackgroundImage(item.mbi_item.image_url, index, this.mediaItemType)} + ${this.styleMediaBrowserItemBackgroundImage(item.mbi_item.image_url, index)} ${(() => { if (this.isTouchDevice) { return (html` @@ -62,7 +56,7 @@ export class MediaBrowserList extends MediaBrowserBase { @touchstart=${{handleEvent: () => this.onMediaBrowserItemTouchStart(customEvent(ITEM_SELECTED, item)), passive: true }} @touchend=${() => this.onMediaBrowserItemTouchEnd(customEvent(ITEM_SELECTED, item))} > -
${renderMediaBrowserItem(item, !item.mbi_item.image_url || !this.hideTitle, !this.hideSubTitle)}
+
${this.renderMediaBrowserItem(item, !item.mbi_item.image_url || !this.hideTitle, !this.hideSubTitle)}
${when( item.mbi_item.is_active && this.store.player.isPlaying() && this.section == Section.DEVICES, () => html`${nowPlayingBars}`, @@ -78,7 +72,7 @@ export class MediaBrowserList extends MediaBrowserBase { @mousedown=${() => this.onMediaBrowserItemMouseDown()} @mouseup=${() => this.onMediaBrowserItemMouseUp(customEvent(ITEM_SELECTED, item))} > -
${renderMediaBrowserItem(item, !item.mbi_item.image_url || !this.hideTitle, !this.hideSubTitle)}
+
${this.renderMediaBrowserItem(item, !item.mbi_item.image_url || !this.hideTitle, !this.hideSubTitle)}
${when( item.mbi_item.is_active && this.store.player.isPlaying() && this.section == Section.DEVICES, () => html`${nowPlayingBars}`, @@ -109,11 +103,17 @@ export class MediaBrowserList extends MediaBrowserBase { margin: 0.4rem 0.0rem; } - .button-source { + .button-device { --icon-width: 50px !important; margin: 0 !important; } + .button-track { + --icon-width: 80px !important; + margin: 0 !important; + padding: 0.25rem; + } + .row { display: flex; } @@ -131,6 +131,12 @@ export class MediaBrowserList extends MediaBrowserBase { font-size: 1.1rem; align-self: center; flex: 1; + color: var(--secondary-text-color); + font-weight: normal; + padding: 0 0.5rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; } /* *********************************************************** */ @@ -178,7 +184,6 @@ export class MediaBrowserList extends MediaBrowserBase { /*.bar:nth-child(10) { left: 37px; animation-duration: 442ms; }*/ `, - styleMediaBrowserItemTitle, listStyle, ]; } diff --git a/src/components/player-body-audiobook.ts b/src/components/player-body-audiobook.ts index 971437b..3f89982 100644 --- a/src/components/player-body-audiobook.ts +++ b/src/components/player-body-audiobook.ts @@ -377,7 +377,7 @@ class PlayerBodyAudiobook extends PlayerBodyBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Action failed: \n" + (error as Error).message); + this.alertErrorSet("Action failed: " + (error as Error).message); return true; } @@ -439,7 +439,7 @@ class PlayerBodyAudiobook extends PlayerBodyBase { // clear results, and reject the promise. this.chapter = undefined; - this.alertErrorSet("Get Episode call failed: \n" + (error as Error).message); + this.alertErrorSet("Get Episode call failed: " + (error as Error).message); reject(error); }) @@ -468,7 +468,7 @@ class PlayerBodyAudiobook extends PlayerBodyBase { // clear results, and reject the promise. this.isAudiobookFavorite = undefined; - this.alertErrorSet("Check Audiobook Favorites failed: \n" + (error as Error).message); + this.alertErrorSet("Check Audiobook Favorites failed: " + (error as Error).message); reject(error); }) @@ -497,7 +497,7 @@ class PlayerBodyAudiobook extends PlayerBodyBase { // clear results, and reject the promise. this.isChapterFavorite = undefined; - this.alertErrorSet("Check Episode Favorites failed: \n" + (error as Error).message); + this.alertErrorSet("Check Episode Favorites failed: " + (error as Error).message); reject(error); }) @@ -529,7 +529,7 @@ class PlayerBodyAudiobook extends PlayerBodyBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Audiobook actions refresh failed: \n" + (error as Error).message); + this.alertErrorSet("Audiobook actions refresh failed: " + (error as Error).message); return true; } diff --git a/src/components/player-body-queue.ts b/src/components/player-body-queue.ts index 2984982..9650c55 100644 --- a/src/components/player-body-queue.ts +++ b/src/components/player-body-queue.ts @@ -226,7 +226,7 @@ export class PlayerBodyQueue extends PlayerBodyBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Action failed: \n" + (error as Error).message); + this.alertErrorSet("Action failed: " + (error as Error).message); return true; } @@ -296,7 +296,7 @@ export class PlayerBodyQueue extends PlayerBodyBase { // clear results, and reject the promise. this.queueInfo = undefined; - this.alertErrorSet("Get Player Queue Info call failed: \n" + (error as Error).message); + this.alertErrorSet("Get Player Queue Info call failed: " + (error as Error).message); reject(error); }) @@ -328,7 +328,7 @@ export class PlayerBodyQueue extends PlayerBodyBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Queue info refresh failed: \n" + (error as Error).message); + this.alertErrorSet("Queue info refresh failed: " + (error as Error).message); return true; } diff --git a/src/components/player-body-show.ts b/src/components/player-body-show.ts index 39161f8..61b1d7f 100644 --- a/src/components/player-body-show.ts +++ b/src/components/player-body-show.ts @@ -339,7 +339,7 @@ class PlayerBodyShow extends PlayerBodyBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Show action failed: \n" + (error as Error).message); + this.alertErrorSet("Show action failed: " + (error as Error).message); return true; } @@ -401,7 +401,7 @@ class PlayerBodyShow extends PlayerBodyBase { // clear results, and reject the promise. this.episode = undefined; - this.alertErrorSet("Get Episode call failed: \n" + (error as Error).message); + this.alertErrorSet("Get Episode call failed: " + (error as Error).message); reject(error); }) @@ -430,7 +430,7 @@ class PlayerBodyShow extends PlayerBodyBase { // clear results, and reject the promise. this.isShowFavorite = undefined; - this.alertErrorSet("Check Show Favorites failed: \n" + (error as Error).message); + this.alertErrorSet("Check Show Favorites failed: " + (error as Error).message); reject(error); }) @@ -459,7 +459,7 @@ class PlayerBodyShow extends PlayerBodyBase { // clear results, and reject the promise. this.isEpisodeFavorite = undefined; - this.alertErrorSet("Check Episode Favorites failed: \n" + (error as Error).message); + this.alertErrorSet("Check Episode Favorites failed: " + (error as Error).message); reject(error); }) @@ -491,7 +491,7 @@ class PlayerBodyShow extends PlayerBodyBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Show actions refresh failed: \n" + (error as Error).message); + this.alertErrorSet("Show actions refresh failed: " + (error as Error).message); return true; } diff --git a/src/components/player-body-track.ts b/src/components/player-body-track.ts index 8045b39..9388f00 100644 --- a/src/components/player-body-track.ts +++ b/src/components/player-body-track.ts @@ -41,6 +41,8 @@ enum Actions { AlbumFavoriteRemove = "AlbumFavoriteRemove", AlbumFavoriteUpdate = "AlbumFavoriteUpdate", AlbumSearchRadio = "AlbumSearchRadio", + AlbumShowTracks = "AlbumShowTracks", + ArtistCopyPresetToClipboard = "ArtistCopyPresetToClipboard", ArtistCopyUriToClipboard = "ArtistCopyUriToClipboard", ArtistFavoriteAdd = "ArtistFavoriteAdd", ArtistFavoriteRemove = "ArtistFavoriteRemove", @@ -54,6 +56,7 @@ enum Actions { ArtistShowAlbumsSingle = "ArtistShowAlbumsSingle", ArtistShowRelatedArtists = "ArtistShowRelatedArtists", ArtistShowTopTracks = "ArtistShowTopTracks", + TrackCopyPresetToClipboard = "TrackCopyPresetToClipboard", TrackCopyUriToClipboard = "TrackCopyUriToClipboard", TrackFavoriteAdd = "TrackFavoriteAdd", TrackFavoriteRemove = "TrackFavoriteRemove", @@ -202,6 +205,10 @@ class PlayerBodyTrack extends PlayerBodyBase {
Copy Track URI to Clipboard
+ this.onClickAction(Actions.TrackCopyPresetToClipboard)}> + +
Copy Track Preset Info to Clipboard
+
`; @@ -211,6 +218,10 @@ class PlayerBodyTrack extends PlayerBodyBase { + this.onClickAction(Actions.AlbumShowTracks)} hide=${this.hideSearchType(SearchMediaTypes.TRACKS)}> + +
Show Album Tracks
+
this.onClickAction(Actions.AlbumSearchRadio)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}>
Search for Album Radio
@@ -275,6 +286,10 @@ class PlayerBodyTrack extends PlayerBodyBase {
Copy Artist URI to Clipboard
+ this.onClickAction(Actions.ArtistCopyPresetToClipboard)}> + +
Copy Artist Preset Info to Clipboard
+
`; @@ -412,6 +427,17 @@ class PlayerBodyTrack extends PlayerBodyBase { this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.track?.album.name + RADIO_SEARCH_KEY + this.track?.artists[0].name)); return true; + } else if (action == Actions.AlbumShowTracks) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ALBUM_TRACKS, this.track?.album.name + "; " + this.track?.artists[0].name, this.track?.album.name, this.track?.album.uri, null, this.track?.album)); + return true; + + } else if (action == Actions.ArtistCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.track?.artists[0])); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.ArtistCopyUriToClipboard) { copyTextToClipboard(this.track?.artists[0].uri || ""); @@ -462,6 +488,12 @@ class PlayerBodyTrack extends PlayerBodyBase { this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ARTIST_TOP_TRACKS, this.track?.artists[0].name, this.track?.artists[0].name, this.track?.artists[0].uri)); return true; + } else if (action == Actions.TrackCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.track, this.track?.artists[0].name)); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.TrackCopyUriToClipboard) { copyTextToClipboard(this.track?.uri || ""); @@ -526,7 +558,7 @@ class PlayerBodyTrack extends PlayerBodyBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Track action failed: \n" + (error as Error).message); + this.alertErrorSet("Track action failed: " + (error as Error).message); return true; } @@ -589,7 +621,7 @@ class PlayerBodyTrack extends PlayerBodyBase { // clear results, and reject the promise. this.track = undefined; - this.alertErrorSet("Get Track call failed: \n" + (error as Error).message); + this.alertErrorSet("Get Track call failed: " + (error as Error).message); reject(error); }) @@ -618,7 +650,7 @@ class PlayerBodyTrack extends PlayerBodyBase { // clear results, and reject the promise. this.isAlbumFavorite = undefined; - this.alertErrorSet("Check Album Favorites failed: \n" + (error as Error).message); + this.alertErrorSet("Check Album Favorites failed: " + (error as Error).message); reject(error); }) @@ -647,7 +679,7 @@ class PlayerBodyTrack extends PlayerBodyBase { // clear results, and reject the promise. this.isArtistFavorite = undefined; - this.alertErrorSet("Check Artist Favorites failed: \n" + (error as Error).message); + this.alertErrorSet("Check Artist Favorites failed: " + (error as Error).message); reject(error); }) @@ -676,7 +708,7 @@ class PlayerBodyTrack extends PlayerBodyBase { // clear results, and reject the promise. this.isTrackFavorite = undefined; - this.alertErrorSet("Check Track Favorites failed: \n" + (error as Error).message); + this.alertErrorSet("Check Track Favorites failed: " + (error as Error).message); reject(error); }) @@ -708,7 +740,7 @@ class PlayerBodyTrack extends PlayerBodyBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Track actions refresh failed: \n" + (error as Error).message); + this.alertErrorSet("Track actions refresh failed: " + (error as Error).message); return true; } diff --git a/src/components/player-controls.ts b/src/components/player-controls.ts index 9e65891..432e712 100644 --- a/src/components/player-controls.ts +++ b/src/components/player-controls.ts @@ -455,7 +455,7 @@ class PlayerControls extends LitElement { catch (error) { // set alert error message. - this.alertErrorSet("Control action failed: \n" + (error as Error).message); + this.alertErrorSet("Control action failed: " + (error as Error).message); this.progressHide(); return true; diff --git a/src/components/player-progress.ts b/src/components/player-progress.ts index b5d4f9f..c8014bf 100644 --- a/src/components/player-progress.ts +++ b/src/components/player-progress.ts @@ -120,7 +120,7 @@ class Progress extends LitElement { catch (error) { // set alert error message. - this.alertErrorSet("Seek position failed: \n" + (error as Error).message); + this.alertErrorSet("Seek position failed: " + (error as Error).message); return true; } diff --git a/src/components/player-volume.ts b/src/components/player-volume.ts index e96e454..db1dd25 100644 --- a/src/components/player-volume.ts +++ b/src/components/player-volume.ts @@ -102,7 +102,7 @@ class Volume extends LitElement { catch (error) { // set alert error message. - this.alertErrorSet("Volume set failed: \n" + (error as Error).message); + this.alertErrorSet("Volume set failed: " + (error as Error).message); return true; } @@ -134,7 +134,7 @@ class Volume extends LitElement { catch (error) { // set alert error message. - this.alertErrorSet("Volume mute failed: \n" + (error as Error).message); + this.alertErrorSet("Volume mute failed: " + (error as Error).message); return true; } @@ -190,7 +190,7 @@ class Volume extends LitElement { catch (error) { // set alert error message. - this.alertErrorSet("Volume action failed: \n" + (error as Error).message); + this.alertErrorSet("Volume action failed: " + (error as Error).message); return true; } diff --git a/src/components/playlist-actions.ts b/src/components/playlist-actions.ts index 4064a52..41e401a 100644 --- a/src/components/playlist-actions.ts +++ b/src/components/playlist-actions.ts @@ -1,7 +1,6 @@ // lovelace card imports. import { css, html, TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; -//import { fireEvent } from 'custom-card-helpers'; import copyTextToClipboard from 'copy-text-to-clipboard'; import { mdiBackupRestore, @@ -23,14 +22,12 @@ import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; import { formatDateHHMMSSFromMilliseconds, unescapeHtml } from '../utils/utils'; import { openWindowNewTab } from '../utils/media-browser-utils'; +import { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD } from '../constants'; import { GetPlaylistPagePlaylistTracks } from '../types/spotifyplus/playlist-page'; import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; -import { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD } from '../constants'; import { IPlaylistSimplified } from '../types/spotifyplus/playlist-simplified'; import { IPlaylistTrack } from '../types/spotifyplus/playlist-track'; -//import { getLovelace, parseLovelaceCardPath } from '../utils/config-util'; - /** * Playlist actions. */ @@ -72,7 +69,7 @@ class PlaylistActions extends FavActionsBase { * 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 { + protected override render(): TemplateResult | void { // invoke base class method. super.render(); @@ -293,97 +290,6 @@ class PlaylistActions extends FavActionsBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; - // the following was my attempt to automatically add the new preset to the - // configuration. it partially worked, in that it would add the preset to - // the configuration in memory, the preset would be displayed in the preset - // browser, but the update was not applied to the lovelace configuration that - // is stored on disk in the `\config\.storage\lovelace.xxxxx` location. - - //// create user preset object. - //const preset: IUserPreset = { - // name: this.mediaItem.name, - // image_url: this.mediaItem.image_url || "", - // subtitle: this.mediaItem.type, - // type: this.mediaItem.type, - // uri: this.mediaItem.uri, - //}; - - //const CRLF = "\n"; - //let presetText = ""; - //presetText += " - name: " + preset.name + CRLF; - //presetText += " subtitle: " + preset.subtitle + CRLF; - //presetText += " image_url: " + preset.image_url + CRLF; - //presetText += " uri: " + preset.uri + CRLF; - //presetText += " type: " + preset.type + CRLF; - - //// add to configuration; insert new item at the beginning. - //this.store.config.userPresets?.unshift(preset); - - //// update configuration (in memory). - //// note that this will ONLY update the configuration stored in memory; it - //// does not apply the updates to the lovelace raw config stored on disk in - //// the `\config\.storage\lovelace.xxxxx` location! - //fireEvent(this, 'config-changed', { config: this.store.config }); - - //// prepare to update the lovelace configuration (on disk). - //const lovelace = getLovelace(); - //if (lovelace) { - - // console.log("%conClickAction - lovelace data:\n- editMode = %s\n- mode = %s\n- locale = %s\n- urlPath = %s", - // "color: gold", - // JSON.stringify(lovelace.editMode), - // JSON.stringify(lovelace.mode), - // JSON.stringify(lovelace.locale), - // JSON.stringify(lovelace.urlPath), - // ); - - // console.log("%conClickAction - lovelace.rawConfig:\n%s", - // "color: red", - // JSON.stringify(lovelace.rawConfig, null, 2), - // ); - - // console.log("%conClickAction - lovelace.config:\n%s", - // "color: gold", - // JSON.stringify(lovelace.config, null, 2), - // ); - - // //export const replaceCard = ( - // // config: LovelaceConfig, - // // path: LovelaceCardPath, - // // cardConfig: LovelaceCardConfig - // //): LovelaceConfig => { - - // // const { cardIndex } = parseLovelaceCardPath(path); - // // const containerPath = getLovelaceContainerPath(path); - - // // const cards = findLovelaceItems("cards", config, containerPath); - - // // const newCards = (cards ?? []).map((origConf, ind) => - // // ind === cardIndex ? cardConfig : origConf - // // ); - - // // const newConfig = updateLovelaceItems( - // // "cards", - // // config, - // // containerPath, - // // newCards - // // ); - // // return newConfig; - // //}; - - // //let config: LovelaceRawConfig; - // //await lovelace.saveConfig(config); <- this is the LovelaceRawConfig, not the card config!!! - - //} else { - - // //console.log("%conClickAction - could not get lovelace object!", - // // "color: red", - // //); - - //} - - //return true; - } // show progress indicator. @@ -418,7 +324,7 @@ class PlaylistActions extends FavActionsBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Action failed: \n" + (error as Error).message); + this.alertErrorSet("Action failed: " + (error as Error).message); return true; } @@ -482,7 +388,7 @@ class PlaylistActions extends FavActionsBase { // clear results, and reject the promise. this.playlistTracks = undefined; - this.alertErrorSet("Get Playlist Items failed: \n" + (error as Error).message); + this.alertErrorSet("Get Playlist Items failed: " + (error as Error).message); reject(error); }) @@ -511,7 +417,7 @@ class PlaylistActions extends FavActionsBase { // clear results, and reject the promise. this.isPlaylistFavorite = undefined; - this.alertErrorSet("Check Playlist Followers failed: \n" + (error as Error).message); + this.alertErrorSet("Check Playlist Followers failed: " + (error as Error).message); reject(error); }) @@ -540,7 +446,7 @@ class PlaylistActions extends FavActionsBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Playlist actions refresh failed: \n" + (error as Error).message); + this.alertErrorSet("Playlist actions refresh failed: " + (error as Error).message); return true; } diff --git a/src/components/show-actions.ts b/src/components/show-actions.ts index 647453f..2241bdc 100644 --- a/src/components/show-actions.ts +++ b/src/components/show-actions.ts @@ -71,7 +71,7 @@ class ShowActions extends FavActionsBase { * 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 { + protected override render(): TemplateResult | void { // invoke base class method. super.render(); @@ -297,7 +297,7 @@ class ShowActions extends FavActionsBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Action failed: \n" + (error as Error).message); + this.alertErrorSet("Action failed: " + (error as Error).message); return true; } @@ -350,7 +350,7 @@ class ShowActions extends FavActionsBase { // clear results, and reject the promise. this.showEpisodes = undefined; - this.alertErrorSet("Get Show Episodes failed: \n" + (error as Error).message); + this.alertErrorSet("Get Show Episodes failed: " + (error as Error).message); reject(error); }) @@ -379,7 +379,7 @@ class ShowActions extends FavActionsBase { // clear results, and reject the promise. this.isShowFavorite = undefined; - this.alertErrorSet("Check Show Favorites failed: \n" + (error as Error).message); + this.alertErrorSet("Check Show Favorites failed: " + (error as Error).message); reject(error); }) @@ -408,7 +408,7 @@ class ShowActions extends FavActionsBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Show actions refresh failed: \n" + (error as Error).message); + this.alertErrorSet("Show actions refresh failed: " + (error as Error).message); return true; } diff --git a/src/components/track-actions.ts b/src/components/track-actions.ts index d2d91d4..ef74811 100644 --- a/src/components/track-actions.ts +++ b/src/components/track-actions.ts @@ -41,6 +41,8 @@ enum Actions { AlbumFavoriteRemove = "AlbumFavoriteRemove", AlbumFavoriteUpdate = "AlbumFavoriteUpdate", AlbumSearchRadio = "AlbumSearchRadio", + AlbumShowTracks = "AlbumShowTracks", + ArtistCopyPresetToClipboard = "ArtistCopyPresetToClipboard", ArtistCopyUriToClipboard = "ArtistCopyUriToClipboard", ArtistFavoriteAdd = "ArtistFavoriteAdd", ArtistFavoriteRemove = "ArtistFavoriteRemove", @@ -54,6 +56,7 @@ enum Actions { ArtistShowAlbumsSingle = "ArtistShowAlbumsSingle", ArtistShowRelatedArtists = "ArtistShowRelatedArtists", ArtistShowTopTracks = "ArtistShowTopTracks", + TrackCopyPresetToClipboard = "TrackCopyPresetToClipboard", TrackCopyUriToClipboard = "TrackCopyUriToClipboard", TrackFavoriteAdd = "TrackFavoriteAdd", TrackFavoriteRemove = "TrackFavoriteRemove", @@ -91,7 +94,7 @@ class TrackActions extends FavActionsBase { * 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 { + protected override render(): TemplateResult | void { // invoke base class method. super.render(); @@ -221,6 +224,10 @@ class TrackActions extends FavActionsBase {
Copy Track URI to Clipboard
+ this.onClickAction(Actions.TrackCopyPresetToClipboard)}> + +
Copy Track Preset Info to Clipboard
+
`; @@ -230,6 +237,10 @@ class TrackActions extends FavActionsBase { + this.onClickAction(Actions.AlbumShowTracks)} hide=${this.hideSearchType(SearchMediaTypes.TRACKS)}> + +
Show Album Tracks
+
this.onClickAction(Actions.AlbumSearchRadio)} hide=${this.hideSearchType(SearchMediaTypes.PLAYLISTS)}>
Search for Album Radio
@@ -294,6 +305,10 @@ class TrackActions extends FavActionsBase {
Copy Artist URI to Clipboard
+ this.onClickAction(Actions.ArtistCopyPresetToClipboard)}> + +
Copy Artist Preset Info to Clipboard
+
`; @@ -433,6 +448,17 @@ class TrackActions extends FavActionsBase { this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.PLAYLISTS, this.mediaItem.album.name + RADIO_SEARCH_KEY + this.mediaItem.artists[0].name)); return true; + } else if (action == Actions.AlbumShowTracks) { + + this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.ALBUM_TRACKS, this.mediaItem.album.name + "; " + this.mediaItem.artists[0].name, this.mediaItem.album.name, this.mediaItem.album.uri, null, this.mediaItem.album)); + return true; + + } else if (action == Actions.ArtistCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.mediaItem.artists[0])); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.ArtistCopyUriToClipboard) { copyTextToClipboard(this.mediaItem.artists[0].uri); @@ -483,6 +509,12 @@ class TrackActions extends FavActionsBase { this.dispatchEvent(SearchMediaEvent(SearchMediaTypes.TRACKS, this.mediaItem.artists[0].name)); return true; + } else if (action == Actions.TrackCopyPresetToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntry(this.mediaItem, this.mediaItem.artists[0].name)); + this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.TrackCopyUriToClipboard) { copyTextToClipboard(this.mediaItem.uri); @@ -553,7 +585,7 @@ class TrackActions extends FavActionsBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Action failed: \n" + (error as Error).message); + this.alertErrorSet("Action failed: " + (error as Error).message); return true; } @@ -604,7 +636,7 @@ class TrackActions extends FavActionsBase { // clear results, and reject the promise. this.isAlbumFavorite = undefined; - this.alertErrorSet("Check Album Favorite failed: \n" + (error as Error).message); + this.alertErrorSet("Check Album Favorite failed: " + (error as Error).message); reject(error); }) @@ -633,7 +665,7 @@ class TrackActions extends FavActionsBase { // clear results, and reject the promise. this.isArtistFavorite = undefined; - this.alertErrorSet("Check Artist Following failed: \n" + (error as Error).message); + this.alertErrorSet("Check Artist Following failed: " + (error as Error).message); reject(error); }) @@ -662,7 +694,7 @@ class TrackActions extends FavActionsBase { // clear results, and reject the promise. this.isTrackFavorite = undefined; - this.alertErrorSet("Check Track Favorites failed: \n" + (error as Error).message); + this.alertErrorSet("Check Track Favorites failed: " + (error as Error).message); reject(error); }) @@ -691,7 +723,7 @@ class TrackActions extends FavActionsBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("Track actions refresh failed: \n" + (error as Error).message); + this.alertErrorSet("Track actions refresh failed: " + (error as Error).message); return true; } diff --git a/src/components/userpreset-actions.ts b/src/components/userpreset-actions.ts index 88e0e95..fefef65 100644 --- a/src/components/userpreset-actions.ts +++ b/src/components/userpreset-actions.ts @@ -34,7 +34,7 @@ class UserPresetActions extends FavActionsBase { * 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 { + protected override render(): TemplateResult | void { // invoke base class method. super.render(); @@ -123,7 +123,7 @@ class UserPresetActions extends FavActionsBase { // clear the progress indicator and set alert error message. this.progressHide(); - this.alertErrorSet("UserPreset actions refresh failed: \n" + (error as Error).message); + this.alertErrorSet("UserPreset actions refresh failed: " + (error as Error).message); return true; } diff --git a/src/constants.ts b/src/constants.ts index 975a701..75891f6 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.12'; +export const CARD_VERSION = '1.0.13'; /** SpotifyPlus integration domain identifier. */ export const DOMAIN_SPOTIFYPLUS = 'spotifyplus'; diff --git a/src/events/search-media.ts b/src/events/search-media.ts index 907942a..a77c626 100644 --- a/src/events/search-media.ts +++ b/src/events/search-media.ts @@ -12,7 +12,10 @@ export const SEARCH_MEDIA = DOMAIN_SPOTIFYPLUS + '-card-search-media'; */ export class SearchMediaEventArgs { - // property storage. + /** + * Parent media item. + */ + public parentMediaItem: any | null; /** * Media type to search. @@ -47,6 +50,7 @@ export class SearchMediaEventArgs { * @param title Title to search for. * @param uri Uri to search for. * @param subtype Item sub-type (if required); for show search type, this should be 'audiobook' or 'podcast'. + * @param parentMediaItem Parent media item. */ constructor( searchType: SearchMediaTypes, @@ -54,12 +58,14 @@ export class SearchMediaEventArgs { title: string | undefined | null = null, uri: string | undefined | null = null, subtype: string | undefined | null = null, + parentMediaItem: any | undefined | null = null, ) { this.searchType = searchType || SearchMediaTypes.PLAYLISTS; this.searchCriteria = searchCriteria || ""; this.title = title || ""; this.uri = uri || ""; this.subtype = subtype || ""; + this.parentMediaItem = parentMediaItem; } } @@ -72,6 +78,7 @@ export class SearchMediaEventArgs { * @param title Title to search for. * @param uri Uri to search for. * @param subtype Item sub-type (if required); for show search type, this should be 'audiobook' or 'podcast'. + * @param parentMediaItem Parent media item. */ export function SearchMediaEvent( searchType: SearchMediaTypes, @@ -79,6 +86,7 @@ export function SearchMediaEvent( title: string | undefined | null = null, uri: string | undefined | null = null, subtype: string | undefined | null = null, + parentMediaItem: any | undefined | null = null, ) { const args = new SearchMediaEventArgs(searchType); @@ -86,6 +94,7 @@ export function SearchMediaEvent( args.title = title || ""; args.uri = uri || ""; args.subtype = subtype || ""; + args.parentMediaItem = parentMediaItem; if (!args.subtype) { if (searchType == SearchMediaTypes.AUDIOBOOK_EPISODES) { diff --git a/src/sections/device-browser.ts b/src/sections/device-browser.ts index 3336c17..bdb3269 100644 --- a/src/sections/device-browser.ts +++ b/src/sections/device-browser.ts @@ -154,6 +154,9 @@ export class DeviceBrowser extends FavBrowserBase { // show progress indicator. this.progressShow(); + // update status. + this.alertInfo = "Transferring playback to device \"" + mediaItem.Name + "\" ..."; + // select the source. await this.store.mediaControlService.select_source(this.player, mediaItem.Name || ''); diff --git a/src/sections/search-media-browser.ts b/src/sections/search-media-browser.ts index 2a42794..04867d5 100644 --- a/src/sections/search-media-browser.ts +++ b/src/sections/search-media-browser.ts @@ -32,6 +32,7 @@ import { getIdFromSpotifyUri } from '../services/spotifyplus-service'; import { storageService } from '../decorators/storage'; import { SearchMediaTypes } from '../types/search-media-types'; import { SearchMediaEventArgs } from '../events/search-media'; +import { ITrack } from '../types/spotifyplus/track'; // debug logging. import Debug from 'debug/src/browser.js'; @@ -107,6 +108,10 @@ export class SearchBrowser extends FavBrowserBase { itemsPerRow = this.config.showFavBrowserItemsPerRow || 4; } else if (searchType == SearchMediaTypes.TRACKS) { itemsPerRow = this.config.trackFavBrowserItemsPerRow || 4; + // album-specific searches: + } else if (searchType == SearchMediaTypes.ALBUM_TRACKS) { + itemsPerRow = this.config.trackFavBrowserItemsPerRow || 4; + this.isFilterCriteriaReadOnly = true; // artists-specific searches: } else if (searchType == SearchMediaTypes.ARTIST_ALBUMS) { itemsPerRow = this.config.albumFavBrowserItemsPerRow || 4; @@ -154,10 +159,11 @@ export class SearchBrowser extends FavBrowserBase { } // set flags that control search media type menu item visibility. + const isSearchArgsUriAlbum = ((this.searchEventArgs?.uri || "").indexOf(":album:") > -1); const isSearchArgsUriArtist = ((this.searchEventArgs?.uri || "").indexOf(":artist:") > -1); const isSearchArgsUriAudiobook = (((this.searchEventArgs?.uri || "").indexOf(":show:") > -1) && (this.searchEventArgs?.subtype == "audiobook")); const isSearchArgsUriShow = (((this.searchEventArgs?.uri || "").indexOf(":show:") > -1) && (this.searchEventArgs?.subtype == "podcast")); - const isSearchArgsUri = isSearchArgsUriArtist || isSearchArgsUriAudiobook || isSearchArgsUriShow; + const isSearchArgsUri = isSearchArgsUriAlbum || isSearchArgsUriArtist || isSearchArgsUriAudiobook || isSearchArgsUriShow; // define control to render - search media type. const searchMediaTypeHtml = html` @@ -194,6 +200,10 @@ export class SearchBrowser extends FavBrowserBase {
${SearchMediaTypes.TRACKS}
+ + +
${SearchMediaTypes.ALBUM_TRACKS}
+
${SearchMediaTypes.ARTIST_TOP_TRACKS}
@@ -287,7 +297,7 @@ export class SearchBrowser extends FavBrowserBase { return (html``); } else if (this.searchMediaType == SearchMediaTypes.SHOWS) { return (html``); - } else if ([SearchMediaTypes.TRACKS, SearchMediaTypes.ARTIST_TOP_TRACKS].indexOf(this.searchMediaType as any) > -1) { + } else if ([SearchMediaTypes.TRACKS, SearchMediaTypes.ALBUM_TRACKS, SearchMediaTypes.ARTIST_TOP_TRACKS].indexOf(this.searchMediaType as any) > -1) { return (html``); } else { return (html``); @@ -678,6 +688,70 @@ export class SearchBrowser extends FavBrowserBase { promiseRequests.push(promiseUpdateMediaList); + } else if (this.searchMediaType == SearchMediaTypes.ALBUM_TRACKS) { + + // create promise - get album tracks. + const promiseGetAlbumTracks = new Promise((resolve, reject) => { + + // update status. + this.alertInfo = "Searching " + this.searchMediaType + " for \"" + this.searchEventArgs?.title + "\" ..."; + + // set service parameters. + const albumId = getIdFromSpotifyUri(this.searchEventArgs?.uri); + const limit_total = this.config.searchMediaBrowserSearchLimit || 50; + const market = null; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetAlbumTracks(player.id, albumId, 0, 0, market, limit_total) + .then(result => { + + if (debuglog.enabled) { + debuglog("%cupdateMediaList - Appending album to SearchMediaTypes.ALBUM_TRACKS items.\n- Album parentMediaItem:\n%s", + "color.red", + JSON.stringify(this.searchEventArgs?.parentMediaItem, null, 2), + ); + } + + // add parent album info to ITrackSimplified objects so that we can just use + // the control (it requires an ITrack object). we do this + // because ITrackSimplified objects do not contain an `album` object. + result.items.forEach(item => { + const track = item as ITrack; + track.album = this.searchEventArgs?.parentMediaItem; + if (track.album) { + track.image_url = track.album.image_url; + } + }) + + // load media list results. + this.mediaList = result.items; + this.mediaListLastUpdatedOn = result.date_last_refreshed || getUtcNowTimestamp(); + + // clear certain info messsages if they are temporary. + if (this.alertInfo?.startsWith("Searching ")) { + this.alertInfoClear(); + } + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.mediaList = undefined; + this.mediaListLastUpdatedOn = 0; + + // call base class method, indicating media list update failed. + super.updatedMediaListError("Spotify " + this.searchMediaType + " search failed: " + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetAlbumTracks); + } else if (this.searchMediaType == SearchMediaTypes.ARTIST_ALBUMS) { // create promise - get artists' compilation albums. diff --git a/src/sections/userpreset-browser.ts b/src/sections/userpreset-browser.ts index d0b953d..163c87c 100644 --- a/src/sections/userpreset-browser.ts +++ b/src/sections/userpreset-browser.ts @@ -102,19 +102,15 @@ export class UserPresetBrowser extends FavBrowserBase { */ protected override onItemSelected(evArgs: CustomEvent) { - // event could contains an IUserPreset item. - const eventType = evArgs.detail.type; - // is this a recommendations type? - if (eventType == "recommendations") { + if (evArgs.detail.type == "recommendations") { const mediaItem = evArgs.detail as IUserPreset; this.PlayTrackRecommendations(mediaItem); } else { - // category playlist was selected; event argument is an IPlayListSimplified item. - // just call base class method to play the media item (it's a playlist). + // call base class method to handle it. super.onItemSelected(evArgs); } @@ -122,6 +118,26 @@ export class UserPresetBrowser extends FavBrowserBase { } + /** + * Handles the `item-selected-with-hold` event fired when a media browser item is clicked and held. + * + * @param args Event arguments that contain the media item that was clicked on. + */ + protected override onItemSelectedWithHold(args: CustomEvent) { + + // is this a recommendations type? + if (args.detail.type == "recommendations") { + // set the uri value to fool the base class validations. + // note that uri property is not used by recommendations. + args.detail.uri = "unknown"; + } + + // call base class method to handle it. + super.onItemSelectedWithHold(args); + + }; + + /** * Calls the SpotifyPlusService PlayerMediaPlayTracks method to play all tracks * returned by the GetTrackRecommendations service for the desired track attributes. @@ -160,7 +176,8 @@ export class UserPresetBrowser extends FavBrowserBase { this.requestUpdate(); // play recommended tracks. - await this.spotifyPlusService.PlayerMediaPlayTracks(this.player.id, uris.join(","), null, null); + const device_id = this.player.attributes.source || null; + await this.spotifyPlusService.PlayerMediaPlayTracks(this.player.id, uris.join(","), null, device_id); // show player section. this.store.card.SetSection(Section.PLAYER); diff --git a/src/services/spotifyplus-service.ts b/src/services/spotifyplus-service.ts index 8cd92f2..1f991ff 100644 --- a/src/services/spotifyplus-service.ts +++ b/src/services/spotifyplus-service.ts @@ -39,6 +39,7 @@ import { IPlayHistoryPage } from '../types/spotifyplus/play-history-page'; import { IShowPageSaved } from '../types/spotifyplus/show-page-saved'; import { IShowPageSimplified } from '../types/spotifyplus/show-page-simplified'; import { IShowSimplified } from '../types/spotifyplus/show-simplified'; +import { ISpotifyConnectDevice } from '../types/spotifyplus/spotify-connect-device'; import { ISpotifyConnectDevices } from '../types/spotifyplus/spotify-connect-devices'; import { ITrack } from '../types/spotifyplus/track'; import { ITrackPage } from '../types/spotifyplus/track-page'; @@ -46,6 +47,7 @@ import { ITrackPageSaved } from '../types/spotifyplus/track-page-saved'; import { ITrackPageSimplified } from '../types/spotifyplus/track-page-simplified'; import { ITrackRecommendations } from '../types/spotifyplus/track-recommendations'; import { ITrackRecommendationsProperties } from '../types/spotifyplus/track-recommendations-properties'; +import { IZeroconfResponse } from '../types/spotifyplus/zeroconf-response'; // debug logging. import Debug from 'debug/src/browser.js'; @@ -2168,6 +2170,93 @@ export class SpotifyPlusService { } + /** + * Get information about a single Spotify Connect player device. + * + * @param device_value The id (e.g. '30fbc80e35598f3c242f2120413c943dfd9715fe') or name (e.g. 'Office') of the Spotify Connect Player device this command is targeting. If an '*' is specified, then the SpotifyPlus default device is used. + * @param verify_user_context If True, the active user context of the resolved device is checked to ensure it matches the Spotify Connect user context specified in the SpotifyPlus configuration options. If False, the user context will not be checked. Default is True. + * @param verify_timeout Maximum time to wait (in seconds) for the device to become active in the Spotify Connect device list. This value is only used if a Connect command has to be issued to activate the device. Default is 5; value range is 0 - 10. + * @param refresh_device_list True to refresh the Spotify Connect device list; otherwise, False to use the Spotify Connect device list cache. Default is True. + * @param activate_device True to activate the device if necessary; otherwise, False. + * @param delay Time delay (in seconds) to wait AFTER issuing any command to the device. This delay will give the spotify zeroconf api time to process the change before another command is issued. Default is 0.25; value range is 0 - 10. + * @returns An ISpotifyConnectDevice object. + */ + public async GetSpotifyConnectDevice( + entity_id: string, + device_value: string, + verify_user_context: boolean | null = null, + verify_timeout: number | null = null, + refresh_device_list: boolean | null = null, + activate_device: boolean | null = null, + delay: number | null = null, + ): Promise { + + try { + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + device_value: device_value, + }; + + // update service data parameters (with optional parameters). + if (verify_user_context) + serviceData['verify_user_context'] = verify_user_context; + if (verify_timeout) + serviceData['verify_timeout'] = verify_timeout; + if (refresh_device_list) + serviceData['refresh_device_list'] = refresh_device_list; + if (activate_device) + serviceData['activate_device'] = activate_device; + if (delay) + serviceData['delay'] = delay; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_spotify_connect_device', + 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 responseObj = response["result"] as ISpotifyConnectDevice; + + // set image_url property based on device type. + if (responseObj != null) { + // set image_url path using mdi icons for common sources. + const sourceCompare = (responseObj.Name || "").toLocaleLowerCase(); + if (sourceCompare.includes('web player (chrome)')) { + responseObj.image_url = getMdiIconImageUrl(mdiGoogleChrome); + } else if (sourceCompare.includes('web player (microsoft edge)')) { + responseObj.image_url = getMdiIconImageUrl(mdiMicrosoftEdge); + } else if (sourceCompare.includes('web player')) { + responseObj.image_url = getMdiIconImageUrl(mdiWeb); + } else { + responseObj.image_url = getMdiIconImageUrl(mdiSpeaker); + } + } + + // trace. + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Service %s response (trimmed):\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(responseObj, null, 2) + ); + } + + // return results to caller. + return responseObj; + + } + finally { + } + } + + /** * Get information about all available Spotify Connect player devices. * @@ -4012,6 +4101,135 @@ export class SpotifyPlusService { } + /** + * Calls the `addUser` Spotify Zeroconf API endpoint to issue a call to SpConnectionLoginBlob. If successful, + * the associated device id is added to the Spotify Connect active device list for the specified user account. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param device A ISpotifyConnectDevice object that contains Spotify Connect details for the device. + * @param username (optional) Spotify Connect user name to login with (e.g. "yourspotifyusername"). If null, the SpotifyPlus integration options username value will be used. + * @param password (optional) Spotify Connect user password to login with. If null, the SpotifyPlus integration options password value will be used. + * @param loginid (optional) Spotify Connect login id to login with (e.g. '31l77fd87g8h9j00k89f07jf87ge'). This is also known as the canonical user id value. This MUST be the value that relates to the `username` argument. + * @param pre_disconnect (optional) True if a Disconnect should be made prior to the Connect call. This will ensure that the active user is logged out, which must be done if switching user accounts; otherwise, False to not issue a Disconnect call. Default is False. + * @param verify_device_list_entry (optional) True to ensure that the device id is present in the Spotify Connect device list before issuing a call to Connect; Connect will not be called if the device id is already in the list; otherwise, False to always call Connect to add the device. Default is False. + * @param delay (optional) (optional) Time delay (in seconds) to wait AFTER issuing a command to the device. This delay will give the spotify zeroconf api time to process the change before another command is issued. Default is 0.50; value range is 0 - 10. + * @returns Response data, in the form of a Record (e.g. dictionary). + */ + public async ZeroconfDeviceConnect( + entity_id: string, + device: ISpotifyConnectDevice, + username: string | undefined | null = null, + password: string | undefined | null = null, + loginid: string | undefined | null = null, + pre_disconnect: boolean | undefined | null = true, + verify_device_list_entry: boolean | undefined | null = true, + delay: number | undefined | null = null, + ): Promise { + + try { + + if (debuglog.enabled) { + debuglog("ZeroconfDeviceDisconnect - device item:\n%s", + JSON.stringify(device, null, 2), + ); + } + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + host_ipv4_address: device.DiscoveryResult.HostIpAddress, + host_ip_port: device.DiscoveryResult.HostIpPort, + cpath: device.DiscoveryResult.SpotifyConnectCPath, + version: device.DiscoveryResult.SpotifyConnectVersion, + use_ssl: (device.DiscoveryResult.ZeroconfApiEndpointAddUser.startsWith("https:")), + }; + + // update service data parameters (with optional parameters). + if (username) + serviceData['username'] = username; + if (password) + serviceData['password'] = password; + if (loginid) + serviceData['loginid'] = loginid; + if (pre_disconnect) + serviceData['pre_disconnect'] = pre_disconnect; + if (verify_device_list_entry) + serviceData['verify_device_list_entry'] = verify_device_list_entry; + if (delay) + serviceData['delay'] = delay; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'zeroconf_device_connect', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + return response["result"]; + + } + finally { + } + } + + + /** + * Calls the `resetUsers` Spotify Zeroconf API endpoint to issue a call to SpConnectionLogout. + * The currently logged in user (if any) will be logged out of Spotify Connect, and the + * device id removed from the active Spotify Connect device list. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param device A ISpotifyConnectDevice object that contains Spotify Connect details for the device. + * @param delay (optional) (optional) Time delay (in seconds) to wait AFTER issuing a command to the device. This delay will give the spotify zeroconf api time to process the change before another command is issued. Default is 0.50; value range is 0 - 10. + * @returns Response data, in the form of a Record (e.g. dictionary). + */ + public async ZeroconfDeviceDisconnect( + entity_id: string, + device: ISpotifyConnectDevice, + delay: number | undefined | null = null, + ): Promise { + + try { + + if (debuglog.enabled) { + debuglog("ZeroconfDeviceDisconnect - device item:\n%s", + JSON.stringify(device, null, 2), + ); + } + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + host_ipv4_address: device.DiscoveryResult.HostIpAddress, + host_ip_port: device.DiscoveryResult.HostIpPort, + cpath: device.DiscoveryResult.SpotifyConnectCPath, + version: device.DiscoveryResult.SpotifyConnectVersion, + use_ssl: (device.DiscoveryResult.ZeroconfApiEndpointAddUser.startsWith("https:")), + }; + + // update service data parameters (with optional parameters). + if (delay) + serviceData['delay'] = delay; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'zeroconf_device_disconnect', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + return response["result"]; + + } + finally { + } + } + + /** ====================================================================================== * The following are common helper methods for SpotifyPlus Card support. * ====================================================================================== **/ diff --git a/src/styles/shared-styles-fav-browser.js b/src/styles/shared-styles-fav-browser.js index 3c2e4eb..3e85190 100644 --- a/src/styles/shared-styles-fav-browser.js +++ b/src/styles/shared-styles-fav-browser.js @@ -69,6 +69,8 @@ export const sharedStylesFavBrowser = css` width: 100%; align-self: center; color: var(--dark-primary-color); + overflow: hidden; + text-overflow: ellipsis; } .media-browser-content { diff --git a/src/types/search-media-types.ts b/src/types/search-media-types.ts index 945b0ae..bd2a5a8 100644 --- a/src/types/search-media-types.ts +++ b/src/types/search-media-types.ts @@ -15,6 +15,7 @@ export enum SearchMediaTypes { // album-specific search types. ALBUM_NEW_RELEASES = "Album New Releases", + ALBUM_TRACKS = "Album Tracks", // artist-specific search types. ARTIST_ALBUMS = "Artist Albums", diff --git a/src/types/spotifyplus/user-preset.ts b/src/types/spotifyplus/user-preset.ts index 8c5e373..7d15687 100644 --- a/src/types/spotifyplus/user-preset.ts +++ b/src/types/spotifyplus/user-preset.ts @@ -1,9 +1,14 @@ -/** - * User preset item configuration object. - */ +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from '../../constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":user-presets"); +// our imports. import { ITrackRecommendationsProperties } from "./track-recommendations-properties"; +/** + * User preset item configuration object. + */ export interface IUserPreset { /** @@ -64,17 +69,140 @@ export function GetUserPresetConfigEntry( subTitle: string | undefined | null = null, ): string { - const CRLF = "\n"; + // create user preset object. + const preset: IUserPreset = { + name: mediaItem.name, + image_url: mediaItem.image_url || "", + subtitle: (subTitle || mediaItem.type), + type: mediaItem.type, + uri: mediaItem.uri, + }; + + // trace. + if (debuglog.enabled) { + debuglog("%cGetUserPresetConfigEntry - preset object:\n%s", + "color: orange", + JSON.stringify(preset, null, 2), + ); + } // create text-representation of user preset object. + const CRLF = "\n"; let presetText = ""; - presetText += " - name: " + mediaItem.name + CRLF; - presetText += " subtitle: " + (subTitle || mediaItem.type) + CRLF; - presetText += " image_url: " + mediaItem.image_url + CRLF; - presetText += " uri: " + mediaItem.uri + CRLF; - presetText += " type: " + mediaItem.type + CRLF; + presetText += " - name: " + preset.name + CRLF; + presetText += " subtitle: " + preset.type + CRLF; + presetText += " image_url: " + preset.image_url + CRLF; + presetText += " uri: " + preset.uri + CRLF; + presetText += " type: " + preset.type + CRLF; // return to caller. return presetText; + + // the following was my attempt to automatically add the new preset to the + // configuration. it partially worked, in that it would add the preset to + // the configuration in memory, the preset would be displayed in the preset + // browser, but the update was not applied to the lovelace configuration that + // is stored on disk in the `\config\.storage\lovelace.xxxxx` location. + // the following was located in the `playlist-actions.ts` module, `onClickAction` method. + + //import { IUserPreset } from '../types/spotifyplus/user-preset'; + //import { fireEvent } from 'custom-card-helpers'; + //import { getLovelace } from '../utils/config-util'; + //import { parseLovelaceCardPath } from '../utils/config-util'; + + //// create user preset object. + //const preset: IUserPreset = { + // name: this.mediaItem.name, + // image_url: this.mediaItem.image_url || "", + // subtitle: this.mediaItem.type, + // type: this.mediaItem.type, + // uri: this.mediaItem.uri, + //}; + + //console.log("%conClickAction - new preset:\n%s", + // "color: gold", + // JSON.stringify(preset,null,2), + //); + + //// add to configuration; insert new item at the beginning. + //this.store.config.userPresets?.unshift(preset); + + //// update configuration (in memory). + //// note that this will ONLY update the configuration stored in memory; it + //// does not apply the updates to the lovelace raw config stored on disk in + //// the `\config\.storage\lovelace.xxxxx` location! + //fireEvent(this, 'config-changed', { config: this.store.config }); + + //// prepare to update the lovelace configuration (on disk). + //const lovelace = getLovelace(); + //if (lovelace) { + + // console.log("%conClickAction - lovelace data:\n- editMode = %s\n- mode = %s\n- locale = %s\n- urlPath = %s", + // "color: gold", + // JSON.stringify(lovelace.editMode), + // JSON.stringify(lovelace.mode), + // JSON.stringify(lovelace.locale), + // JSON.stringify(lovelace.urlPath), + // ); + + // console.log("%conClickAction - lovelace.rawConfig:\n%s", + // "color: red", + // JSON.stringify(lovelace.rawConfig, null, 2), + // ); + + // console.log("%conClickAction - lovelace.config:\n%s", + // "color: gold", + // JSON.stringify(lovelace.config, null, 2), + // ); + + // // find nearest `` tag above the `` tag; + // // this will contain the `path` property, which is a LovelaceCardPath object. + // const cardOptions = closestElement("hui-card-options", this) as any | null; + // if (!cardOptions) { + // console.log("%conClickAction - could not find parent tag!", + // "color: red", + // ); + // } + + // console.log("%conClickAction - cardOptions.path:\n%s", + // "color: gold", + // JSON.stringify(cardOptions?.path, null, 2), + // ); + + //// //export const replaceCard = ( + //// // config: LovelaceConfig, + //// // path: LovelaceCardPath, + //// // cardConfig: LovelaceCardConfig + //// //): LovelaceConfig => { + + //// // const { cardIndex } = parseLovelaceCardPath(path); + //// // const containerPath = getLovelaceContainerPath(path); + + //// // const cards = findLovelaceItems("cards", config, containerPath); + + //// // const newCards = (cards ?? []).map((origConf, ind) => + //// // ind === cardIndex ? cardConfig : origConf + //// // ); + + //// // const newConfig = updateLovelaceItems( + //// // "cards", + //// // config, + //// // containerPath, + //// // newCards + //// // ); + //// // return newConfig; + //// //}; + + //// //let config: LovelaceRawConfig; + //// //await lovelace.saveConfig(config); <- this is the LovelaceRawConfig, not the card config!!! + + //} else { + + // console.log("%conClickAction - could not get lovelace object!", + // "color: red", + // ); + + //} + } diff --git a/src/utils/config-util.ts b/src/utils/config-util.ts index 979fae5..6ee0c7d 100644 --- a/src/utils/config-util.ts +++ b/src/utils/config-util.ts @@ -101,6 +101,25 @@ export function getLovelace(): Lovelace | null { } +//export const getLovelace = () => { +// // eslint-disable-next-line @typescript-eslint/no-explicit-any +// let root: any = document.querySelector('home-assistant'); +// root = root && root.shadowRoot; +// root = root && root.querySelector('home-assistant-main'); +// root = root && root.shadowRoot; +// root = root && root.querySelector('app-drawer-layout partial-panel-resolver, ha-drawer partial-panel-resolver'); +// root = (root && root.shadowRoot) || root; +// root = root && root.querySelector('ha-panel-lovelace'); +// root = root && root.shadowRoot; +// root = root && root.querySelector('hui-root'); +// if (root) { +// const ll = root.lovelace; +// ll.current_view = root.___curView; +// return ll; +// } +// return null; +//} + export type LovelaceCardPath = [number, number] | [number, number, number]; export type LovelaceContainerPath = [number] | [number, number]; diff --git a/src/utils/media-browser-utils.ts b/src/utils/media-browser-utils.ts index 19e27d1..33e1378 100644 --- a/src/utils/media-browser-utils.ts +++ b/src/utils/media-browser-utils.ts @@ -1,28 +1,8 @@ -// lovelace card imports. -import { css, html } from 'lit'; - // our imports. import { MediaPlayer } from '../model/media-player'; import { CustomImageUrls } from '../types/custom-image-urls'; import { CardConfig } from '../types/card-config'; -import { Section } from '../types/section'; -import { Store } from '../model/store'; -import { formatDateEpochSecondsToLocaleString, formatStringProperCase } from './utils'; -import { IAlbumSimplified } from '../types/spotifyplus/album-simplified'; -import { IArtist } from '../types/spotifyplus/artist'; -import { IAudiobookSimplified, GetAudiobookAuthors } from '../types/spotifyplus/audiobook-simplified'; -import { IEpisode } from '../types/spotifyplus/episode'; -import { IMediaBrowserInfo, IMediaBrowserItem } from '../types/media-browser-item'; -import { IPlaylistSimplified } from '../types/spotifyplus/playlist-simplified'; -import { IShowSimplified } from '../types/spotifyplus/show-simplified'; -import { ISpotifyConnectDevice } from '../types/spotifyplus/spotify-connect-device'; -import { ITrackSimplified } from '../types/spotifyplus/track-simplified'; -import { IUserPreset } from '../types/spotifyplus/user-preset'; -import { SearchMediaTypes } from '../types/search-media-types'; -import { ICategory } from '../types/spotifyplus/category'; - -const DEFAULT_MEDIA_IMAGEURL = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAZAAAAGQCAYAAACAvzbMAAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw1AUhU9TS0UqDnYQcchQnexiRXQrVSyChdJWaNXB5KV/0KQhSXFxFFwLDv4sVh1cnHV1cBUEwR8QZwcnRRcp8b6k0CLGC4/3cd49h/fuA4RWjalmXxxQNcvIJBNivrAqBl8RgA8hxDAnMVNPZRdz8Kyve+qluovyLO++P2tQKZoM8InEcaYbFvEG8cympXPeJw6ziqQQnxNPGnRB4keuyy6/cS47LPDMsJHLzBOHicVyD8s9zCqGSjxNHFFUjfKFvMsK5y3Oaq3BOvfkLwwVtZUs12mNIYklpJCGCBkNVFGDhSjtGikmMnSe8PCPOv40uWRyVcHIsYA6VEiOH/wPfs/WLMWm3KRQAgi82PbHOBDcBdpN2/4+tu32CeB/Bq60rr/eAmY/SW92tcgRMLQNXFx3NXkPuNwBRp50yZAcyU9LKJWA9zP6pgIwfAsMrLlz65zj9AHI0ayWb4CDQ2CiTNnrHu/u753bvz2d+f0A+AZy3KgprtwAAAAGYktHRAD/AP8A/6C9p5MAAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfoBQEMNhNCJ/KVAAACg0lEQVR42u3BgQAAAADDoPlTX+EAVQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwG/GFwABsN92WwAAAABJRU5ErkJggg=='; +import { formatDateEpochSecondsToLocaleString } from './utils'; /** @@ -68,6 +48,11 @@ export function getCustomImageUrl(collection: CustomImageUrls | undefined, title * Gets the image url that will be displayed in the media browser for items that contain * an image_url attribute. * + * @param item media item to render an image for. + * @param config card configuration object. + * @param hasItemsWithImage true if any items in the parent collection have an image_url assigned; otherwise, false to indicate ALL items have no images. + * @param imageUrlDefault default image url to use. + * * The image to display is resolved in the following sequence: * - configuration `customImageUrls` `title` for matching item name (if one exists). * - item image_url value (if one exists). @@ -76,18 +61,25 @@ export function getCustomImageUrl(collection: CustomImageUrls | undefined, title * * If the image_url is a Home Assistant brands logo, then the brand icon.png image is used instead. */ -export function getContentItemImageUrl(item: any, config: CardConfig, itemsWithImage: boolean, imageUrlDefault: string) { +export function getContentItemImageUrl(item: any, config: CardConfig, hasItemsWithImage: boolean, imageUrlDefault: string) { + + // if there are no other items with images then we are done; + if (!hasItemsWithImage) { + return undefined; + } // check for a custom imageUrl; if not found, then use the item image_url (if supplied). let imageUrl = getCustomImageUrl(config.customImageUrls, item.name || '') ?? item.image_url; // did we resolve an image_url? if (!imageUrl) { + // no - if there are other items with images, then we will use a default image; // otherwise, just return undefined so it doesn't insert a default image. - if (itemsWithImage) { - imageUrl = config.customImageUrls?.['default'] || imageUrlDefault; - } + //if (hasItemsWithImage) { + imageUrl = config.customImageUrls?.['default'] || imageUrlDefault; + //} + } // if imageUrl is a home assistant brands logo, then use the 'icon.png' image. @@ -120,117 +112,13 @@ export function getMdiIconImageUrl(mdi_icon: string): string { * @param items List of media content items to check. * @returns true if ANY of the items have an image_url specified; otherwise, false. */ -function hasItemsWithImage(items: any[]) { +export function hasMediaItemImages(items: any[]) { return items.some((item) => item.image_url); } -/** - * Appends IMediaBrowserItem properties to each item in a collection of items - * that are destined to be displayed in the media browser. - * - * @items Collection of items to display in the media browser. - * @config CardConfig object that contains card configuration details. - * @mediaItemType Type of media items displayed (a Section value). - * @searchMediaType Current search media type, if section is SEARCH_MEDIA. - * @store Common application storage area. - * @returns The collection of items, with each item containing IMediaListItem arguments that will be used by the media browser. - */ -export function buildMediaBrowserItems(items: any, config: CardConfig, mediaItemType: Section, searchMediaType: SearchMediaTypes | null, store: Store) { - - // do ANY of the items have images? returns true if so, otherwise false. - const itemsWithImage = hasItemsWithImage(items); - - // process all items in the collection. - return items.map((item) => { - - //console.log("%c buildMediaBrowserItems - media list item:\n%s", - // "color: yellow;", - // JSON.stringify(item), - //); - - // build media browser info item, that will be merged with the base item. - // get image to use as a thumbnail for the item; - // if no image can be obtained, then use the default. - const mbi_info: IMediaBrowserInfo = { - image_url: getContentItemImageUrl(item, config, itemsWithImage, DEFAULT_MEDIA_IMAGEURL), - title: item.name, - subtitle: item.type, - is_active: false, - }; - - // modify subtitle value based on selected section type. - if (mediaItemType == Section.ALBUM_FAVORITES) { - const itemInfo = (item as IAlbumSimplified); - if ((itemInfo.artists) && (itemInfo.artists.length > 0)) { - if (searchMediaType == SearchMediaTypes.ARTIST_ALBUMS) { - mbi_info.subtitle = itemInfo.release_date || itemInfo.artists[0]?.name || (itemInfo.total_tracks || 0 + " tracks") || item.type; - } else { - mbi_info.subtitle = itemInfo.artists[0]?.name || (itemInfo.total_tracks || 0 + " tracks") || item.type; - } - } - } else if (mediaItemType == Section.ARTIST_FAVORITES) { - const itemInfo = (item as IArtist); - mbi_info.subtitle = ((itemInfo?.followers?.total || 0) + " followers") || item.type; - } else if (mediaItemType == Section.AUDIOBOOK_FAVORITES) { - const itemInfo = (item as IAudiobookSimplified); - mbi_info.subtitle = GetAudiobookAuthors(itemInfo, ", ") || item.type; - } else if (mediaItemType == Section.CATEGORYS) { - const itemInfo = (item as ICategory); - mbi_info.subtitle = itemInfo.type; - } else if (mediaItemType == Section.DEVICES) { - // for device item, the object uses Camel-case names, so we have to use "Name" instead of "name". - // we will also show the device brand and model names as the subtitle. - // we will also indicate which device is active. - const device = (item as ISpotifyConnectDevice); - mbi_info.title = device.Name; - mbi_info.subtitle = (device.DeviceInfo.BrandDisplayName || "unknown") + ", " + (device.DeviceInfo.ModelDisplayName || "unknown"); - mbi_info.is_active = (item.Name == store.player.attributes.source); - } else if (mediaItemType == Section.EPISODE_FAVORITES) { - // spotify search episode returns an IEpisodeSimplified, so show property will be null. - // for search results, use release date for subtitle. - // for favorite results, use the show name for subtitle. - const itemInfo = (item as IEpisode); - mbi_info.subtitle = itemInfo.show?.name || itemInfo.release_date || ""; - } else if (mediaItemType == Section.PLAYLIST_FAVORITES) { - const itemInfo = (item as IPlaylistSimplified); - mbi_info.subtitle = (itemInfo.tracks?.total || 0) + " tracks"; - } else if (mediaItemType == Section.RECENTS) { - // nothing to do here - already set. - } else if (mediaItemType == Section.SHOW_FAVORITES) { - const itemInfo = (item as IShowSimplified); - mbi_info.subtitle = (itemInfo.total_episodes || 0) + " episodes"; - } else if (mediaItemType == Section.TRACK_FAVORITES) { - const itemInfo = (item as ITrackSimplified); - if ((itemInfo.artists) && (itemInfo.artists.length > 0)) { - mbi_info.subtitle = itemInfo.artists[0].name || item.type; - } - } else if (mediaItemType == Section.USERPRESETS) { - const itemInfo = (item as IUserPreset); - mbi_info.subtitle = itemInfo.subtitle || item.uri; - } else { - console.log("%cmedia-browser-utils - unknown mediaItemType = %s; mbi_info not set!", "color:red", JSON.stringify(mediaItemType)); - } - - //console.log("%c buildMediaBrowserItems - media browser item:\n%s", - // "color: yellow;", - // JSON.stringify({ - // ...item, - // mbi_item: mbi_info, - // }), - //); - - // append media browser item arguments to the item. - return { - ...item, - mbi_item: mbi_info - }; - }); -} - - /** * Formats a string with various configuration information. This method finds selected keywords * and replaces them with the equivalent attribute values. @@ -393,63 +281,6 @@ export function formatConfigInfo( } -/** - * Style definition used to style a media browser item background image. - */ -export function styleMediaBrowserItemBackgroundImage(thumbnail: string, index: number, mediaItemType: Section) { - - let bgSize = '100%'; - if (mediaItemType == Section.DEVICES) { - bgSize = '50%'; - } - - return html` - - `; -} - - -/** - * Style definition used to style a media browser item title. - */ -export const styleMediaBrowserItemTitle = css` - .title { - color: var(--secondary-text-color); - font-weight: normal; - padding: 0 0.5rem; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } -`; - - -export function renderMediaBrowserItem( - item: IMediaBrowserItem, - showTitle: boolean = true, - showSubTitle: boolean = true, -) { - - let clsActive = '' - if (item.mbi_item.is_active) { - clsActive = ' title-active'; - } - - return html` -
-
- ${item.mbi_item.title} -
${formatStringProperCase(item.mbi_item.subtitle || '')}
-
- `; -} - - export function truncateMediaList(mediaList: any, maxItems: number): string | undefined { let result: string | undefined = undefined; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index b7caf7a..820f4ae 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -432,9 +432,9 @@ export function getObjectDifferences(obj1: any, obj2: any): any { * * examples: * - find element by it's `id=` value: - * const container = this.closestElement('#spcPlayer'); + * const container = this.closestElement('#spcPlayer', this); * - find element by it's html tag name (e.g. ``): - * const container = this.closestElement('spc-player'); + * const container = this.closestElement('spc-player', this); */ export function closestElement(selector: string, base: Element) { @@ -535,23 +535,3 @@ export function copyToClipboard(ev): boolean { window.status = "text copied to clipboard"; return result; } - - -//export const getLovelace = () => { -// // eslint-disable-next-line @typescript-eslint/no-explicit-any -// let root: any = document.querySelector('home-assistant'); -// root = root && root.shadowRoot; -// root = root && root.querySelector('home-assistant-main'); -// root = root && root.shadowRoot; -// root = root && root.querySelector('app-drawer-layout partial-panel-resolver, ha-drawer partial-panel-resolver'); -// root = (root && root.shadowRoot) || root; -// root = root && root.querySelector('ha-panel-lovelace'); -// root = root && root.shadowRoot; -// root = root && root.querySelector('hui-root'); -// if (root) { -// const ll = root.lovelace; -// ll.current_view = root.___curView; -// return ll; -// } -// return null; -//} From 37e21e271bf5b1721f4a8dc90ff3a0bdfa966425 Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Mon, 25 Nov 2024 10:08:56 -0600 Subject: [PATCH 04/17] [ 1.0.14 ] * Non-Administrator accounts can now use the card without receiving the `unauthorized` message. Note that non-administrators cannot change the card configuration (as designed). * Changed the way calls are made to the underlying SpotifyPlus integration services. Calls are now made using the `hass.callService` method instead of the `hass.connection.sendMessagePromise` with type `execute_script`. This was causing all calls that returned service response data to fail with `unauthorized` errors. * Removed references to `custom-card-helpers` npm package, as it was outdated and is not being maintained. We will now create our own card helpers when needed. * Added reference to `home-assistant-js-websocket` version 9.4.0, as it was a dependency of `custom-card-helpers` npm package. --- CHANGELOG.md | 15 ++- SpotifyPlusCard.njsproj | 9 +- package-lock.json | 98 ++----------------- package.json | 2 +- src/card.ts | 2 +- src/components/media-browser-base.ts | 2 + src/constants.ts | 2 +- src/editor/base-editor.ts | 3 +- src/model/store.ts | 2 +- src/sections/fav-browser-base.ts | 5 +- src/sections/player.ts | 5 +- src/services/hass-service.ts | 4 +- src/services/media-control-service.ts | 4 +- src/services/spotifyplus-service.ts | 33 +++---- src/types.ts | 12 --- src/types/card-config.ts | 2 +- src/types/home-assistant-ex.ts | 73 -------------- .../home-assistant-frontend/fire-event.ts | 92 +++++++++++++++++ .../home-assistant-frontend/home-assistant.ts | 80 +++++++++++++++ .../lovelace-card-config.ts | 92 +++++++++++++++++ .../service-call-request.ts | 0 .../service-call-response.ts | 0 src/types/spotifyplus/user-preset.ts | 2 +- src/utils/config-util.ts | 2 +- 24 files changed, 327 insertions(+), 214 deletions(-) delete mode 100644 src/types/home-assistant-ex.ts create mode 100644 src/types/home-assistant-frontend/fire-event.ts create mode 100644 src/types/home-assistant-frontend/home-assistant.ts create mode 100644 src/types/home-assistant-frontend/lovelace-card-config.ts rename src/types/{ => home-assistant-frontend}/service-call-request.ts (100%) rename src/types/{ => home-assistant-frontend}/service-call-response.ts (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index eca5f0d..4c62b2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,16 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.14 ] - 2024/11/25 + + * Non-Administrator accounts can now use the card without receiving the `unauthorized` message. Note that non-administrators cannot change the card configuration (as designed). + * Changed the way calls are made to the underlying SpotifyPlus integration services. Calls are now made using the `hass.callService` method instead of the `hass.connection.sendMessagePromise` with type `execute_script`. This was causing all calls that returned service response data to fail with `unauthorized` errors. + * Removed references to `custom-card-helpers` npm package, as it was outdated and is not being maintained. We will now create our own card helpers when needed. + * Added reference to `home-assistant-js-websocket` version 9.4.0, as it was a dependency of `custom-card-helpers` npm package. + ###### [ 1.0.13 ] - 2024/11/20 - * This release requires the SpotifyPlus v1.0.66+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. + * This release requires the SpotifyPlus Integration v1.0.66+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. * Added "Copy Preset Info to Clipboard" action for track and artist in the player track details action menu. This will create a user-preset configuration entry for the selected media and copy it to the clipboard; the entry can then be pasted into the configuration editor under the `userPresets:` key, which will create a user preset for the media item. * Added "Copy Preset Info to Clipboard" action for track and artist in the favorites track details action menu. This will create a user-preset configuration entry for the selected media and copy it to the clipboard; the entry can then be pasted into the configuration editor under the `userPresets:` key, which will create a user preset for the media item. * Added "Show Album Tracks" action for all album action menus. This will display all tracks on the album in the search browser. @@ -18,7 +25,7 @@ Change are listed in reverse chronological order (newest to oldest). ###### [ 1.0.12 ] - 2024/11/15 - * This release requires the SpotifyPlus v1.0.65+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. + * This release requires the SpotifyPlus Integration v1.0.65+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. * Added category browser: browse Spotify playlists by categories; existing card configurations have to enable the section in the general configuration settings. * Added dynamic track recommendation capability to user-defined presets. Simply put, you define a preset with the parameters of what you want to play and Spotify searches its media catalog for tracks that match. The matching tracks are then added to a play queue and played in random order. The matching tracks will change over time, as Spotify adds new content to its media catalog. * Added action for all playable media types: Copy Preset Info to Clipboard. This will create a user-preset configuration entry for the selected media and copy it to the clipboard; the entry can then be pasted into the configuration editor under the `userPresets:` key, which will create a user preset for the media item. @@ -38,7 +45,7 @@ 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. + * This release requires the SpotifyPlus Integration 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. @@ -54,7 +61,7 @@ Change are listed in reverse chronological order (newest to oldest). ###### [ 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. + * This release requires the SpotifyPlus Integration v1.0.63 release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. * Added `searchMediaBrowserSearchTypes` config option to enable / disable selected search types. * Added `playerControlsHidePlayQueue` config option to enable / disable play queue information area. diff --git a/SpotifyPlusCard.njsproj b/SpotifyPlusCard.njsproj index 51df37b..4ffcc62 100644 --- a/SpotifyPlusCard.njsproj +++ b/SpotifyPlusCard.njsproj @@ -113,6 +113,7 @@ + @@ -180,11 +181,15 @@ + + + + + - @@ -248,8 +253,6 @@ - - diff --git a/package-lock.json b/package-lock.json index e72985f..c870b75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,8 @@ "@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", + "home-assistant-js-websocket": "^9.4.0", "lit": "^2.8.0", "node-vibrant": "^3.2.1-alpha.1", "url": "^0.11.4" @@ -661,59 +661,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@formatjs/ecma402-abstract": { - "version": "1.11.4", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.4.tgz", - "integrity": "sha512-EBikYFp2JCdIfGEb5G9dyCkTGDmC57KSHhRQOC3aYxoPWVZvfWCDjZwkGYHN7Lis/fmuWl906bnNTJifDQ3sXw==", - "dependencies": { - "@formatjs/intl-localematcher": "0.2.25", - "tslib": "^2.1.0" - } - }, - "node_modules/@formatjs/fast-memoize": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-1.2.1.tgz", - "integrity": "sha512-Rg0e76nomkz3vF9IPlKeV+Qynok0r7YZjL6syLz4/urSg0IbjPZCB/iYUMNsYA643gh4mgrX3T7KEIFIxJBQeg==", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.1.0.tgz", - "integrity": "sha512-Qxv/lmCN6hKpBSss2uQ8IROVnta2r9jd3ymUEIjm2UyIkUCHVcbUVRGL/KS/wv7876edvsPe+hjHVJ4z8YuVaw==", - "dependencies": { - "@formatjs/ecma402-abstract": "1.11.4", - "@formatjs/icu-skeleton-parser": "1.3.6", - "tslib": "^2.1.0" - } - }, - "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.6.tgz", - "integrity": "sha512-I96mOxvml/YLrwU2Txnd4klA7V8fRhb6JG/4hm3VMNmeJo1F03IpV2L3wWt7EweqNLES59SZ4d6hVOPCSf80Bg==", - "dependencies": { - "@formatjs/ecma402-abstract": "1.11.4", - "tslib": "^2.1.0" - } - }, - "node_modules/@formatjs/intl-localematcher": { - "version": "0.2.25", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.25.tgz", - "integrity": "sha512-YmLcX70BxoSopLFdLr1Ds99NdlTI2oWoLbaUW2M406lxOIPzE1KQhRz2fPUkq34xVZQaihCoU29h0KK7An3bhA==", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@formatjs/intl-utils": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-3.8.4.tgz", - "integrity": "sha512-j5C6NyfKevIxsfLK8KwO1C0vvP7k1+h4A9cFpc+cr6mEwCc1sPkr17dzh0Ke6k9U5pQccAQoXdcNBl3IYa4+ZQ==", - "deprecated": "the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package", - "dependencies": { - "emojis-list": "^3.0.0" - } - }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -3713,20 +3660,6 @@ "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", "dev": true }, - "node_modules/custom-card-helpers": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/custom-card-helpers/-/custom-card-helpers-1.9.0.tgz", - "integrity": "sha512-5IW4OXq3MiiCqDvqeu+MYsM1NmntKW1WfJhyJFsdP2tbzqEI4BOnqRz2qzdp08lE4QLVhYfRLwe0WAqgQVNeFg==", - "dependencies": { - "@formatjs/intl-utils": "^3.8.4", - "home-assistant-js-websocket": "^6.0.1", - "intl-messageformat": "^9.11.1", - "lit": "^2.1.1", - "rollup": "^2.63.0", - "superstruct": "^0.15.3", - "typescript": "^4.5.4" - } - }, "node_modules/data-urls": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", @@ -4001,6 +3934,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, "engines": { "node": ">= 4" } @@ -5099,6 +5033,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -5446,9 +5381,9 @@ } }, "node_modules/home-assistant-js-websocket": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/home-assistant-js-websocket/-/home-assistant-js-websocket-6.1.1.tgz", - "integrity": "sha512-TnZFzF4mn5F/v0XKUTK2GMQXrn/+eQpgaSDSELl6U0HSwSbFwRhGWLz330YT+hiKMspDflamsye//RPL+zwhDw==" + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/home-assistant-js-websocket/-/home-assistant-js-websocket-9.4.0.tgz", + "integrity": "sha512-312TuI63IfKf8G+iWvKmPYIdxWMNojwVk03o9OSpQFFDjSCNAYdCUfuPCFs73SuJ1Xpd4D1Eo11CB33MGMqZ+Q==" }, "node_modules/html-encoding-sniffer": { "version": "2.0.1", @@ -5640,17 +5575,6 @@ "node": ">=10.13.0" } }, - "node_modules/intl-messageformat": { - "version": "9.13.0", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-9.13.0.tgz", - "integrity": "sha512-7sGC7QnSQGa5LZP7bXLDhVDtQOeKGeBFGHF2Y8LVBwYZoQZCgWeKoPGTa5GMG8g/TzDgeXuYJQis7Ggiw2xTOw==", - "dependencies": { - "@formatjs/ecma402-abstract": "1.11.4", - "@formatjs/fast-memoize": "1.2.1", - "@formatjs/icu-messageformat-parser": "2.1.0", - "tslib": "^2.1.0" - } - }, "node_modules/is-array-buffer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", @@ -9268,6 +9192,7 @@ "version": "2.79.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "dev": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -9952,11 +9877,6 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/superstruct": { - "version": "0.15.5", - "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-0.15.5.tgz", - "integrity": "sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ==" - }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -10556,7 +10476,8 @@ "node_modules/tslib": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", - "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "dev": true }, "node_modules/type-check": { "version": "0.4.0", @@ -10677,6 +10598,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 388f1b4..c36b033 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "@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", + "home-assistant-js-websocket": "^9.4.0", "lit": "^2.8.0", "node-vibrant": "^3.2.1-alpha.1", "url": "^0.11.4" diff --git a/src/card.ts b/src/card.ts index da29109..68c136b 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1,10 +1,10 @@ // lovelace card imports. -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, query, state } from 'lit/decorators.js'; import { choose } from 'lit/directives/choose.js'; import { when } from 'lit/directives/when.js'; +import { HomeAssistant } from './types/home-assistant-frontend/home-assistant'; // our imports - card sections and editor. import './sections/album-fav-browser'; // SECTION.ALBUM_FAVORITES diff --git a/src/components/media-browser-base.ts b/src/components/media-browser-base.ts index 62fae89..e0ada25 100644 --- a/src/components/media-browser-base.ts +++ b/src/components/media-browser-base.ts @@ -622,6 +622,8 @@ export class MediaBrowserBase extends LitElement { } else if (this.mediaItemType == Section.USERPRESETS) { const itemInfo = (item as IUserPreset); mbi_info.subtitle = itemInfo.subtitle || item.uri; + } else if (this.mediaItemType == Section.PLAYER) { + // this condition can be ignored, as the player does not contain a media-browser. } else { console.log("%cmedia-browser-utils - unknown mediaItemType = %s; mbi_info not set!", "color:red", JSON.stringify(this.mediaItemType)); } diff --git a/src/constants.ts b/src/constants.ts index 75891f6..59ded42 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.13'; +export const CARD_VERSION = '1.0.14'; /** SpotifyPlus integration domain identifier. */ export const DOMAIN_SPOTIFYPLUS = 'spotifyplus'; diff --git a/src/editor/base-editor.ts b/src/editor/base-editor.ts index e856a49..5f09864 100644 --- a/src/editor/base-editor.ts +++ b/src/editor/base-editor.ts @@ -1,7 +1,8 @@ // lovelace card imports. import { css, LitElement } from 'lit'; import { property } from 'lit/decorators.js'; -import { fireEvent, HomeAssistant } from 'custom-card-helpers'; +import { HomeAssistant } from '../types/home-assistant-frontend/home-assistant'; +import { fireEvent } from '../types/home-assistant-frontend/fire-event'; // our imports. import { CardConfig } from '../types/card-config'; diff --git a/src/model/store.ts b/src/model/store.ts index 4257d5d..bc6c718 100644 --- a/src/model/store.ts +++ b/src/model/store.ts @@ -1,5 +1,5 @@ // lovelace card imports. -import { HomeAssistant } from 'custom-card-helpers'; +import { HomeAssistant } from '../types/home-assistant-frontend/home-assistant'; // our imports. import { HassService } from '../services/hass-service'; diff --git a/src/sections/fav-browser-base.ts b/src/sections/fav-browser-base.ts index 0e95d3d..33fc6a3 100644 --- a/src/sections/fav-browser-base.ts +++ b/src/sections/fav-browser-base.ts @@ -1,7 +1,7 @@ // lovelace card imports. import { css, html, LitElement, PropertyValues, TemplateResult } from 'lit'; import { property, query, state } from 'lit/decorators.js'; -import { HomeAssistant } from 'custom-card-helpers'; +import { HomeAssistant } from '../types/home-assistant-frontend/home-assistant'; import { mdiArrowLeft, mdiRefresh, @@ -742,7 +742,8 @@ export class FavBrowserBase extends LitElement { this.alertInfoClear(); if (debuglog.enabled) { - debuglog("updatedMediaListError - %s", + debuglog("%cupdatedMediaListError - %s", + "color:red", JSON.stringify(alertErrorMessage), ); } diff --git a/src/sections/player.ts b/src/sections/player.ts index 73af683..5c490cf 100644 --- a/src/sections/player.ts +++ b/src/sections/player.ts @@ -20,7 +20,6 @@ import '../components/player-volume'; import { CardConfig } from '../types/card-config'; import { Store } from '../model/store'; 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'; @@ -393,7 +392,7 @@ export class Player extends LitElement implements playerAlerts { if (oldPlayer) { oldImage = (oldPlayer.attributes.entity_picture || oldPlayer.attributes.entity_picture_local); if (oldImage) { - oldImage = (this.store.hass as HomeAssistantEx).hassUrl(oldImage); + oldImage = this.store.hass.hassUrl(oldImage); oldMediaContentId = oldPlayer.attributes.media_content_id; } } @@ -406,7 +405,7 @@ export class Player extends LitElement implements playerAlerts { // if image not set, then there's nothing left to do. newImage = (this.store.player.attributes.entity_picture || this.store.player.attributes.entity_picture_local); if (newImage) { - newImage = (this.store.hass as HomeAssistantEx).hassUrl(newImage); + newImage = this.store.hass.hassUrl(newImage); newMediaContentId = this.store.player.attributes.media_content_id; this.playerImage = newImage; } else { diff --git a/src/services/hass-service.ts b/src/services/hass-service.ts index 0d2c9aa..c051eaa 100644 --- a/src/services/hass-service.ts +++ b/src/services/hass-service.ts @@ -1,7 +1,7 @@ // lovelace card imports. -import { HomeAssistant } from 'custom-card-helpers'; -import { ServiceCallRequest } from 'custom-card-helpers/dist/types'; import { HassEntity } from 'home-assistant-js-websocket'; +import { HomeAssistant } from '../types/home-assistant-frontend/home-assistant'; +import { ServiceCallRequest } from '../types/home-assistant-frontend/service-call-request'; // our imports. import { MediaPlayer } from '../model/media-player'; diff --git a/src/services/media-control-service.ts b/src/services/media-control-service.ts index 1bfd093..4186b0d 100644 --- a/src/services/media-control-service.ts +++ b/src/services/media-control-service.ts @@ -1,8 +1,8 @@ // lovelace card imports. -import { ServiceCallRequest } from 'custom-card-helpers/dist/types'; +import { HassService } from './hass-service'; +import { ServiceCallRequest } from '../types/home-assistant-frontend/service-call-request'; // our imports. -import { HassService } from './hass-service'; import { MediaPlayerItem } from '../types'; import { MediaPlayer } from '../model/media-player'; import { DOMAIN_MEDIA_PLAYER } from '../constants'; diff --git a/src/services/spotifyplus-service.ts b/src/services/spotifyplus-service.ts index 1f991ff..6db6f33 100644 --- a/src/services/spotifyplus-service.ts +++ b/src/services/spotifyplus-service.ts @@ -1,6 +1,6 @@ // lovelace card imports. -import { HomeAssistant } from 'custom-card-helpers'; -import { ServiceCallRequest } from 'custom-card-helpers/dist/types'; +import { HomeAssistant } from '../types/home-assistant-frontend/home-assistant'; +import { ServiceCallRequest } from '../types/home-assistant-frontend/service-call-request'; import { mdiGoogleChrome, mdiMicrosoftEdge, @@ -10,7 +10,6 @@ import { // our imports. import { DOMAIN_SPOTIFYPLUS } from '../constants'; -import { ServiceCallResponse } from '../types/service-call-response'; import { MediaPlayer } from '../model/media-player'; import { getMdiIconImageUrl } from '../utils/media-browser-utils'; import { SearchMediaTypes } from '../types/search-media-types'; @@ -135,20 +134,20 @@ export class SpotifyPlusService { ); } - // call the service as a script. - const serviceResponse = await this.hass.connection.sendMessagePromise({ - type: "execute_script", - sequence: [{ - "service": serviceRequest.domain + "." + serviceRequest.service, - "data": serviceRequest.serviceData, - "target": serviceRequest.target, - "response_variable": "service_result" - }, - { - "stop": "done", - "response_variable": "service_result" - }] - }); + //// ensure user is administrator; left this in here in case we need it in the future. + //if (!this.hass.user.is_admin) { + // throw Error("User account \"" + this.hass.user.name + "\" is not an Administrator; execute_script cannot be called by non-administrator accounts"); + //} + + // call the service. + const serviceResponse = await this.hass.callService( + serviceRequest.domain, + serviceRequest.service, + serviceRequest.serviceData, + serviceRequest.target, + undefined, // notify on error + true, // return response data + ) //if (debuglog.enabled) { // debuglog("%cCallServiceWithResponse - Service %s response:\n%s", diff --git a/src/types.ts b/src/types.ts index 28a2e0f..885bce9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,3 @@ -import { HomeAssistant } from 'custom-card-helpers'; - declare global { // noinspection JSUnusedGlobalSymbols interface Window { @@ -21,13 +19,3 @@ export interface MediaPlayerItem { export interface TemplateResult { result: string[]; } - -interface HassEntityExtended { - platform: string; -} - -export interface HomeAssistantWithEntities extends HomeAssistant { - entities: { - [entity_id: string]: HassEntityExtended; - }; -} diff --git a/src/types/card-config.ts b/src/types/card-config.ts index 95ebe7d..9da2725 100644 --- a/src/types/card-config.ts +++ b/src/types/card-config.ts @@ -1,5 +1,5 @@ // lovelace card imports. -import { LovelaceCardConfig } from 'custom-card-helpers'; +import { LovelaceCardConfig } from '../types/home-assistant-frontend/lovelace-card-config'; // our imports. import { Section } from './section'; diff --git a/src/types/home-assistant-ex.ts b/src/types/home-assistant-ex.ts deleted file mode 100644 index e86b7f9..0000000 --- a/src/types/home-assistant-ex.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { HomeAssistant } from 'custom-card-helpers'; - - -export interface HomeAssistantEx extends HomeAssistant { - - //auth: Auth & { external?: ExternalMessaging }; - //connection: Connection; - //connected: boolean; - //states: HassEntities; - //entities: { [id: string]: EntityRegistryDisplayEntry }; - //devices: { [id: string]: DeviceRegistryEntry }; - //areas: { [id: string]: AreaRegistryEntry }; - //services: HassServices; - //config: HassConfig; - //themes: Themes; - //selectedTheme: ThemeSettings | null; - //panels: Panels; - //panelUrl: string; - //// i18n - //// current effective language in that order: - //// - backend saved user selected language - //// - language in local app storage - //// - browser language - //// - english (en) - //language: string; - //// local stored language, keep that name for backward compatibility - //selectedLanguage: string | null; - //locale: FrontendLocaleData; - //resources: Resources; - //localize: LocalizeFunc; - //translationMetadata: TranslationMetadata; - //suspendWhenHidden: boolean; - //enableShortcuts: boolean; - //vibrate: boolean; - //debugConnection: boolean; - //dockedSidebar: "docked" | "always_hidden" | "auto"; - //defaultPanel: string; - //moreInfoEntityId: string | null; - //user?: CurrentUser; - //userData?: CoreFrontendUserData | null; - hassUrl(path?: string): string; -// callService( -// domain: ServiceCallRequest["domain"], -// service: ServiceCallRequest["service"], -// serviceData?: ServiceCallRequest["serviceData"], -// target?: ServiceCallRequest["target"], -// notifyOnError?: boolean, -// returnResponse?: boolean -// ): Promise; -// callApi( -// method: "GET" | "POST" | "PUT" | "DELETE", -// path: string, -// parameters?: Record, -// headers?: Record -// ): Promise; -// fetchWithAuth(path: string, init?: Record): Promise; -// sendWS(msg: MessageBase): void; -// callWS(msg: MessageBase): Promise; -// loadBackendTranslation( -// category: Parameters[2], -// integrations?: Parameters[3], -// configFlow?: Parameters[4] -// ): Promise; -// loadFragmentTranslation(fragment: string): Promise; -// formatEntityState(stateObj: HassEntity, state?: string): string; -// formatEntityAttributeValue( -// stateObj: HassEntity, -// attribute: string, -// value?: any -// ): string; -// formatEntityAttributeName(stateObj: HassEntity, attribute: string): string; -} - diff --git a/src/types/home-assistant-frontend/fire-event.ts b/src/types/home-assistant-frontend/fire-event.ts new file mode 100644 index 0000000..6e32892 --- /dev/null +++ b/src/types/home-assistant-frontend/fire-event.ts @@ -0,0 +1,92 @@ +// cloned from the following location: +// home-assistant-frontend\src\common\dom\fire_event.ts + +// Polymer legacy event helpers used courtesy of the Polymer project. +// +// Copyright (c) 2017 The Polymer Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +declare global { + // tslint:disable-next-line + interface HASSDomEvents { } +} + +declare global { + // the following are just a FEW of the events. + interface HASSDomEvents { + "config-changed": any; // ConfigChangedEvent; + "GUImode-changed": any; // GUIModeChangedEvent; + "edit-detail-element": any; // EditDetailElementEvent; + "edit-sub-element": any; // EditSubElementEvent; + } +} + + +export type ValidHassDomEvent = keyof HASSDomEvents; + +export interface HASSDomEvent extends Event { + detail: T; +} + +/** + * Dispatches a custom event with an optional detail value. + * + * @param {string} type Name of event type. + * @param {*=} detail Detail value containing event-specific + * payload. + * @param {{ bubbles: (boolean|undefined), + * cancelable: (boolean|undefined), + * composed: (boolean|undefined) }=} + * options Object specifying options. These may include: + * `bubbles` (boolean, defaults to `true`), + * `cancelable` (boolean, defaults to false), and + * `node` on which to fire the event (HTMLElement, defaults to `this`). + * @return {Event} The new event that was fired. + */ +export const fireEvent = ( + node: HTMLElement | Window, + type: HassEvent, + detail?: HASSDomEvents[HassEvent], + options?: { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; + } +) => { + options = options || {}; + // @ts-ignore + detail = detail === null || detail === undefined ? {} : detail; + const event = new Event(type, { + bubbles: options.bubbles === undefined ? true : options.bubbles, + cancelable: Boolean(options.cancelable), + composed: options.composed === undefined ? true : options.composed, + }); + (event as any).detail = detail; + node.dispatchEvent(event); + return event; +}; diff --git a/src/types/home-assistant-frontend/home-assistant.ts b/src/types/home-assistant-frontend/home-assistant.ts new file mode 100644 index 0000000..8b5528b --- /dev/null +++ b/src/types/home-assistant-frontend/home-assistant.ts @@ -0,0 +1,80 @@ +import { HassEntities, HassConfig, Auth, Connection, MessageBase, HassServices } from "home-assistant-js-websocket"; +import { ServiceCallRequest } from './service-call-request'; +import { ServiceCallResponse } from './service-call-response'; + +export interface HomeAssistant { + auth: Auth & { external?: any }; // ExternalMessaging }; + connection: Connection; + connected: boolean; + states: HassEntities; + entities: { [id: string]: any }; // EntityRegistryDisplayEntry }; + devices: { [id: string]: any }; // DeviceRegistryEntry }; + areas: { [id: string]: any }; // AreaRegistryEntry }; + floors: { [id: string]: any }; // FloorRegistryEntry }; + services: HassServices; + config: HassConfig; + themes: any; // Themes; + selectedTheme: any; // ThemeSettings | null; + panels: any; // Panels; + panelUrl: string; + // i18n + // current effective language in that order: + // - backend saved user selected language + // - language in local app storage + // - browser language + // - english (en) + language: string; + // local stored language, keep that name for backward compatibility + selectedLanguage: string | null; + locale: any; // FrontendLocaleData; + resources: any; // Resources; + localize: any; // LocalizeFunc; + translationMetadata: any; // TranslationMetadata; + suspendWhenHidden: boolean; + enableShortcuts: boolean; + vibrate: boolean; + debugConnection: boolean; + dockedSidebar: "docked" | "always_hidden" | "auto"; + defaultPanel: string; + moreInfoEntityId: string | null; + user?: any; // CurrentUser; + userData?: any; // CoreFrontendUserData | null; + hassUrl(path?): string; + callService( + domain: ServiceCallRequest["domain"], + service: ServiceCallRequest["service"], + serviceData?: ServiceCallRequest["serviceData"], + target?: ServiceCallRequest["target"], + notifyOnError?: boolean, + returnResponse?: boolean + ): Promise; + callApi( + method: "GET" | "POST" | "PUT" | "DELETE", + path: string, + parameters?: Record, + headers?: Record + ): Promise; + callApiRaw( // introduced in 2024.11 + method: "GET" | "POST" | "PUT" | "DELETE", + path: string, + parameters?: Record, + headers?: Record, + signal?: AbortSignal + ): Promise; + fetchWithAuth(path: string, init?: Record): Promise; + sendWS(msg: MessageBase): void; + callWS(msg: MessageBase): Promise; +// loadBackendTranslation( +// category: Parameters[2], +// integrations?: Parameters[3], +// configFlow?: Parameters[4] +// ): Promise; +// loadFragmentTranslation(fragment: string): Promise; +// formatEntityState(stateObj: HassEntity, state?: string): string; +// formatEntityAttributeValue( +// stateObj: HassEntity, +// attribute: string, +// value?: any +// ): string; +// formatEntityAttributeName(stateObj: HassEntity, attribute: string): string; +} diff --git a/src/types/home-assistant-frontend/lovelace-card-config.ts b/src/types/home-assistant-frontend/lovelace-card-config.ts new file mode 100644 index 0000000..f8c2f09 --- /dev/null +++ b/src/types/home-assistant-frontend/lovelace-card-config.ts @@ -0,0 +1,92 @@ +//import type { Condition } from "../../../panels/lovelace/common/validate-condition"; +//import type { +// LovelaceGridOptions, +// LovelaceLayoutOptions, +//} from "../../../panels/lovelace/types"; + +export interface LovelaceCardConfig { + index?: number; + view_index?: number; + view_layout?: any; + /** @deprecated Use `grid_options` instead */ + layout_options?: LovelaceLayoutOptions; + grid_options?: LovelaceGridOptions; + type: string; + [key: string]: any; + visibility?: Condition[]; +} + + +export type LovelaceLayoutOptions = { + grid_columns?: number | "full"; + grid_rows?: number | "auto"; + grid_max_columns?: number; + grid_min_columns?: number; + grid_min_rows?: number; + grid_max_rows?: number; +}; + + +export type LovelaceGridOptions = { + columns?: number | "full"; + rows?: number | "auto"; + max_columns?: number; + min_columns?: number; + min_rows?: number; + max_rows?: number; +}; + + +export type Condition = + | NumericStateCondition + | StateCondition + | ScreenCondition + | UserCondition + | OrCondition + | AndCondition; + + +// Legacy conditional card condition +export interface LegacyCondition { + entity?: string; + state?: string | string[]; + state_not?: string | string[]; +} + +interface BaseCondition { + condition: string; +} + +export interface NumericStateCondition extends BaseCondition { + condition: "numeric_state"; + entity?: string; + below?: string | number; + above?: string | number; +} + +export interface StateCondition extends BaseCondition { + condition: "state"; + entity?: string; + state?: string | string[]; + state_not?: string | string[]; +} + +export interface ScreenCondition extends BaseCondition { + condition: "screen"; + media_query?: string; +} + +export interface UserCondition extends BaseCondition { + condition: "user"; + users?: string[]; +} + +export interface OrCondition extends BaseCondition { + condition: "or"; + conditions?: Condition[]; +} + +export interface AndCondition extends BaseCondition { + condition: "and"; + conditions?: Condition[]; +} diff --git a/src/types/service-call-request.ts b/src/types/home-assistant-frontend/service-call-request.ts similarity index 100% rename from src/types/service-call-request.ts rename to src/types/home-assistant-frontend/service-call-request.ts diff --git a/src/types/service-call-response.ts b/src/types/home-assistant-frontend/service-call-response.ts similarity index 100% rename from src/types/service-call-response.ts rename to src/types/home-assistant-frontend/service-call-response.ts diff --git a/src/types/spotifyplus/user-preset.ts b/src/types/spotifyplus/user-preset.ts index 7d15687..e7728fc 100644 --- a/src/types/spotifyplus/user-preset.ts +++ b/src/types/spotifyplus/user-preset.ts @@ -107,7 +107,7 @@ export function GetUserPresetConfigEntry( // the following was located in the `playlist-actions.ts` module, `onClickAction` method. //import { IUserPreset } from '../types/spotifyplus/user-preset'; - //import { fireEvent } from 'custom-card-helpers'; + //import { fireEvent } from '../types/home-assistant-frontend/fire-event'; //import { getLovelace } from '../utils/config-util'; //import { parseLovelaceCardPath } from '../utils/config-util'; diff --git a/src/utils/config-util.ts b/src/utils/config-util.ts index 6ee0c7d..bf58a0f 100644 --- a/src/utils/config-util.ts +++ b/src/utils/config-util.ts @@ -1,4 +1,4 @@ -import { HomeAssistant } from 'custom-card-helpers'; +import { HomeAssistant } from '../types/home-assistant-frontend/home-assistant'; import type { Connection } from "home-assistant-js-websocket"; //import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; From e731fb07f74f8b7ebcac4e3852dcd8c32597af4f Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Mon, 2 Dec 2024 20:44:55 -0600 Subject: [PATCH 05/17] [ 1.0.15 ] * This release requires the SpotifyPlus Integration v1.0.67+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. * Some Spotify Web API functionality has been deprecated unexpectedly (and without prior notice!) by the Spotify Development Team, and has affected SpotifyPlus Card functionality. More information can be found on the [SpotifyPlus Card Troubleshooting Guide](https://github.com/thlucas1/spotifyplus_card/wiki/Troubleshooting-Guide#issue---sam1010e-deprecated-error-messages) wiki page, as well as the [Spotify Developer Forum Blog](https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api). * Due to the above chnages made by Spotify, any Algorithmic and Spotify-owned editorial playlists are no longer accessible or have more limited functionality. This means that you can no longer obtain details via the `SpotifyClient.GetPlaylist` and `SpotifyClient.GetPlaylistItems` methods for Spotify-owned / generated content (e.g. "Made For You", etc). A `404 - Not Found` error will be returned when trying to retrieve information for these playlist types. * Added category shortcut capability to user-defined presets. This will allow you to quickly display category playlists. This change is irrelevant though, as the support for category playlists was deprecated by the above Spotify Development team changes to their API! * I am leaving the deprecated functionality within the card for the time being, with the hope that Spotify changes it's mind and restores the functionality. --- CHANGELOG.md | 8 ++++ SpotifyPlusCard.njsproj | 3 ++ src/card.ts | 55 +++++++++++++++++++-- src/events/category-display.ts | 71 ++++++++++++++++++++++++++++ src/sections/category-browser.ts | 27 +++++++++++ src/sections/search-media-browser.ts | 2 +- src/sections/userpreset-browser.ts | 6 +++ src/utils/config-util.ts | 7 ++- 8 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 src/events/category-display.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c62b2a..d6a0a50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.15 ] - 2024/12/02 + + * This release requires the SpotifyPlus Integration v1.0.67+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. + * Some Spotify Web API functionality has been deprecated unexpectedly (and without prior notice!) by the Spotify Development Team, and has affected SpotifyPlus Card functionality. More information can be found on the [SpotifyPlus Card Troubleshooting Guide](https://github.com/thlucas1/spotifyplus_card/wiki/Troubleshooting-Guide#issue---sam1010e-deprecated-error-messages) wiki page, as well as the [Spotify Developer Forum Blog](https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api). + * Due to the above chnages made by Spotify, any Algorithmic and Spotify-owned editorial playlists are no longer accessible or have more limited functionality. This means that you can no longer obtain details via the `SpotifyClient.GetPlaylist` and `SpotifyClient.GetPlaylistItems` methods for Spotify-owned / generated content (e.g. "Made For You", etc). A `404 - Not Found` error will be returned when trying to retrieve information for these playlist types. + * Added category shortcut capability to user-defined presets. This will allow you to quickly display category playlists. This change is irrelevant though, as the support for category playlists was deprecated by the above Spotify Development team changes to their API! + * I am leaving the deprecated functionality within the card for the time being, with the hope that Spotify changes it's mind and restores the functionality. + ###### [ 1.0.14 ] - 2024/11/25 * Non-Administrator accounts can now use the card without receiving the `unauthorized` message. Note that non-administrators cannot change the card configuration (as designed). diff --git a/SpotifyPlusCard.njsproj b/SpotifyPlusCard.njsproj index 4ffcc62..be987ff 100644 --- a/SpotifyPlusCard.njsproj +++ b/SpotifyPlusCard.njsproj @@ -141,6 +141,9 @@ + + Code + diff --git a/src/card.ts b/src/card.ts index 68c136b..6386035 100644 --- a/src/card.ts +++ b/src/card.ts @@ -25,6 +25,7 @@ import './editor/editor'; // our imports. import { SEARCH_MEDIA, SearchMediaEventArgs } from './events/search-media'; +import { CATEGORY_DISPLAY, CategoryDisplayEventArgs } from './events/category-display'; 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'; @@ -35,6 +36,7 @@ import { CardConfig } from './types/card-config'; import { CustomImageUrls } from './types/custom-image-urls'; import { SearchMediaTypes } from './types/search-media-types'; import { SearchBrowser } from './sections/search-media-browser'; +import { CategoryBrowser } from './sections/category-browser'; import { formatTitleInfo, removeSpecialChars } from './utils/media-browser-utils'; import { BRAND_LOGO_IMAGE_BASE64, BRAND_LOGO_IMAGE_SIZE, FOOTER_ICON_SIZE_DEFAULT } from './constants'; import { @@ -97,7 +99,7 @@ export class Card extends LitElement { @state() private playerId!: string; @query("#elmSearchMediaBrowserForm", false) private elmSearchMediaBrowserForm!: SearchBrowser; - + @query("#elmCategoryBrowserForm", false) private elmCategoryBrowserForm!: CategoryBrowser; /** Indicates if createStore method is executing for the first time (true) or not (false). */ private isFirstTimeSetup: boolean = true; @@ -169,7 +171,7 @@ export class Card extends LitElement { [Section.ALBUM_FAVORITES, () => html``], [Section.ARTIST_FAVORITES, () => html``], [Section.AUDIOBOOK_FAVORITES, () => html``], - [Section.CATEGORYS, () => html``], + [Section.CATEGORYS, () => html``], [Section.DEVICES, () => html``], [Section.EPISODE_FAVORITES, () => html``], [Section.PLAYER, () => html``], @@ -412,6 +414,7 @@ export class Card extends LitElement { this.addEventListener(PROGRESS_ENDED, this.onProgressEndedEventHandler); this.addEventListener(PROGRESS_STARTED, this.onProgressStartedEventHandler); this.addEventListener(SEARCH_MEDIA, this.onSearchMediaEventHandler); + this.addEventListener(CATEGORY_DISPLAY, this.onCategoryDisplayEventHandler); // only add the following events if card configuration is being edited. if (isCardInEditPreview(this)) { @@ -440,6 +443,7 @@ export class Card extends LitElement { this.removeEventListener(PROGRESS_ENDED, this.onProgressEndedEventHandler); this.removeEventListener(PROGRESS_STARTED, this.onProgressStartedEventHandler); this.removeEventListener(SEARCH_MEDIA, this.onSearchMediaEventHandler); + this.removeEventListener(CATEGORY_DISPLAY, this.onCategoryDisplayEventHandler); // the following event is only added when the card configuration editor is created. // always remove the following events, as isCardInEditPreview() can sometimes @@ -686,7 +690,52 @@ export class Card extends LitElement { } else { // section is not activated; cannot search. - debuglog("onSearchMediaEventHandler - Search section is not enabled; ignoring search request:\n%s", + debuglog("%conSearchMediaEventHandler - Search section is not enabled; ignoring search request:\n%s", + "color:red", + JSON.stringify(evArgs, null, 2), + ); + + } + } + + + /** + * Handles the `CATEGORY_DISPLAY` event. + * This will display the specified category list in the event arguments. + * + * @param ev Event definition and arguments. + */ + protected onCategoryDisplayEventHandler = (ev: Event) => { + + // map event arguments. + const evArgs = (ev as CustomEvent).detail as CategoryDisplayEventArgs; + + // is section activated? if so, then select it. + if (this.config.sections?.includes(Section.CATEGORYS)) { + + // show the category section. + this.section = Section.CATEGORYS; + this.store.section = this.section; + + // wait just a bit before displaying the category. + setTimeout(() => { + + if (debuglog.enabled) { + debuglog("onCategoryDisplayEventHandler - displaying category:\n%s", + JSON.stringify(evArgs, null, 2), + ); + } + + // display category. + this.elmCategoryBrowserForm.displayCategory(evArgs); + + }, 250); + + } else { + + // section is not activated; cannot search. + debuglog("%conCategoryDisplayEventHandler - Category section is not enabled; ignoring display request:\n%s", + "color:red", JSON.stringify(evArgs, null, 2), ); diff --git a/src/events/category-display.ts b/src/events/category-display.ts new file mode 100644 index 0000000..b01fd20 --- /dev/null +++ b/src/events/category-display.ts @@ -0,0 +1,71 @@ +import { DOMAIN_SPOTIFYPLUS } from '../constants'; + +/** + * Uniquely identifies the event. + * */ +export const CATEGORY_DISPLAY = DOMAIN_SPOTIFYPLUS + '-card-category-display'; + + +/** + * Event arguments. + */ +export class CategoryDisplayEventArgs { + + /** + * Criteria used to filter the category entries + */ + public filterCriteria: string; + + /** + * Title to search for. + */ + public title: string | undefined | null; + + /** + * Uri to search for. + */ + public uri: string | undefined | null; + + /** + * Initializes a new instance of the class. + * + * @param filterCriteria Criteria used to filter the category entries. + * @param title Title to search for. + * @param uri Uri to search for. + */ + constructor( + filterCriteria: string | undefined | null = null, + title: string | undefined | null = null, + uri: string | undefined | null = null, + ) { + this.filterCriteria = filterCriteria || ""; + this.title = title || ""; + this.uri = uri || ""; + } +} + + +/** + * Event constructor. + * + * @param filterCriteria Criteria used to filter the category entries. + * @param title Title to search for. + * @param uri Uri to search for. + */ +export function CategoryDisplayEvent( + filterCriteria: string | undefined | null, + title: string | undefined | null = null, + uri: string | undefined | null = null, +) { + + const args = new CategoryDisplayEventArgs(); + args.filterCriteria = (filterCriteria || "").trim(); + args.title = title || ""; + args.uri = uri || ""; + + return new CustomEvent(CATEGORY_DISPLAY, { + bubbles: true, + composed: true, + detail: args, + }); +} diff --git a/src/sections/category-browser.ts b/src/sections/category-browser.ts index 0a425c4..36235fd 100644 --- a/src/sections/category-browser.ts +++ b/src/sections/category-browser.ts @@ -9,6 +9,7 @@ import '../components/playlist-actions'; import { FavBrowserBase } from './fav-browser-base'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; +import { CategoryDisplayEventArgs } from '../events/category-display'; import { formatTitleInfo } from '../utils/media-browser-utils'; import { getUtcNowTimestamp } from '../utils/utils'; import { ICategory } from '../types/spotifyplus/category'; @@ -17,6 +18,7 @@ import { IPlaylistSimplified } from '../types/spotifyplus/playlist-simplified'; // debug logging. import Debug from 'debug/src/browser.js'; import { DEBUG_APP_NAME } from '../constants'; +import { getIdFromSpotifyUri } from '../services/spotifyplus-service'; const debuglog = Debug(DEBUG_APP_NAME + ":category-browser"); @@ -148,6 +150,31 @@ export class CategoryBrowser extends FavBrowserBase { } + /** + * Display category based on passed arguments. + */ + public displayCategory(args: CategoryDisplayEventArgs): void { + + if (debuglog.enabled) { + debuglog("displayCategory - displaying Spotify category:\n%s", + JSON.stringify(args, null, 2), + ); + } + + // save category id and display playlists for the selected category. + this.categoryId = getIdFromSpotifyUri(args.uri) || ""; + this.isCategoryVisible = true; + this.categoryListFilter = args.title || ""; + this.categoryListScrollTopSaved = this.scrollTopSaved; + this.filterCriteria = args.filterCriteria; + this.categoryPlaylists = undefined; + this.requestUpdate(); + this.updateMediaList(this.player); + + } + + + /** * Handles the `click` event fired when the hide or refresh actions icon is clicked. * diff --git a/src/sections/search-media-browser.ts b/src/sections/search-media-browser.ts index 04867d5..374c02b 100644 --- a/src/sections/search-media-browser.ts +++ b/src/sections/search-media-browser.ts @@ -419,7 +419,7 @@ export class SearchBrowser extends FavBrowserBase { /** - * Toggle action visibility - queue items body. + * Execute search based on passed arguments. */ public searchExecute(args: SearchMediaEventArgs): void { diff --git a/src/sections/userpreset-browser.ts b/src/sections/userpreset-browser.ts index 163c87c..c8f5f6b 100644 --- a/src/sections/userpreset-browser.ts +++ b/src/sections/userpreset-browser.ts @@ -12,6 +12,7 @@ import { MediaPlayer } from '../model/media-player'; import { formatTitleInfo } from '../utils/media-browser-utils'; import { getUtcNowTimestamp } from '../utils/utils'; import { IUserPreset } from '../types/spotifyplus/user-preset'; +import { CategoryDisplayEvent } from '../events/category-display'; @customElement("spc-userpreset-browser") @@ -108,6 +109,11 @@ export class UserPresetBrowser extends FavBrowserBase { const mediaItem = evArgs.detail as IUserPreset; this.PlayTrackRecommendations(mediaItem); + } else if (evArgs.detail.type == "category") { + + const preset = evArgs.detail as IUserPreset; + this.dispatchEvent(CategoryDisplayEvent(preset.subtitle, preset.name, preset.uri)); + } else { // call base class method to handle it. diff --git a/src/utils/config-util.ts b/src/utils/config-util.ts index bf58a0f..8606c6f 100644 --- a/src/utils/config-util.ts +++ b/src/utils/config-util.ts @@ -81,16 +81,15 @@ export function getLovelace(): Lovelace | null { if (root) { // dump property keys of HUI-ROOT: //for (const key of Object.keys(root)) { - // //console.log("root property key: " + key) // + ": " + root[key]); - // console.log("root property key: " + key + " = " + root[key]); + // //console.log("root property key: " + key + " = " + root[key]); //} const ll = root.lovelace; //if (!ll) { - // console.log("%cLL root.lovelace not found - getting root.__lovelace", "color:red"); + // //console.log("%cLL root.lovelace not found - getting root.__lovelace", "color:red"); // ll = root.__lovelace; //} //if (!ll) { - // console.log("%cLL root.lovelace not found - getting root[__lovelace]", "color:red"); + // //console.log("%cLL root.lovelace not found - getting root[__lovelace]", "color:red"); // ll = root["__lovelace"]; //} //console.log("%cLL 06 = %s", "color:red", ll) From 4f0542202c305fe64ac6ebfd64e4a3918cbffcb5 Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Fri, 6 Dec 2024 12:56:30 -0600 Subject: [PATCH 06/17] [ 1.0.16 ] * This release requires the SpotifyPlus Integration v1.0.68+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. * Added "Active User" information to Spotify Connect Device details display. --- CHANGELOG.md | 5 ++++ src/components/device-actions.ts | 3 +++ src/constants.ts | 2 +- src/services/spotifyplus-service.ts | 38 ++++++++++++++--------------- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a0a50..00c33da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.16 ] - 2024/12/06 + + * This release requires the SpotifyPlus Integration v1.0.68+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. + * Added "Active User" information to Spotify Connect Device details display. + ###### [ 1.0.15 ] - 2024/12/02 * This release requires the SpotifyPlus Integration v1.0.67+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. diff --git a/src/components/device-actions.ts b/src/components/device-actions.ts index 0902c41..7ccc134 100644 --- a/src/components/device-actions.ts +++ b/src/components/device-actions.ts @@ -151,6 +151,9 @@ class DeviceActions extends FavActionsBase {
Library Version
${this.deviceInfo?.DeviceInfo.LibraryVersion}
+
Active User
+
${this.deviceInfo?.DeviceInfo.ActiveUser}
+
`; diff --git a/src/constants.ts b/src/constants.ts index 59ded42..2b2f4d3 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.14'; +export const CARD_VERSION = '1.0.16'; /** SpotifyPlus integration domain identifier. */ export const DOMAIN_SPOTIFYPLUS = 'spotifyplus'; diff --git a/src/services/spotifyplus-service.ts b/src/services/spotifyplus-service.ts index 6db6f33..5cb4431 100644 --- a/src/services/spotifyplus-service.ts +++ b/src/services/spotifyplus-service.ts @@ -691,7 +691,7 @@ export class SpotifyPlusService { serviceData['market'] = market; if (limit_total) serviceData['limit_total'] = limit_total; - if (sort_result) + if (sort_result != null) serviceData['sort_result'] = sort_result; // create service request. @@ -865,7 +865,7 @@ export class SpotifyPlusService { serviceData['market'] = market; if (limit_total) serviceData['limit_total'] = limit_total; - if (sort_result) + if (sort_result != null) serviceData['sort_result'] = sort_result; // create service request. @@ -999,7 +999,7 @@ export class SpotifyPlusService { // update service data parameters (with optional parameters). if (artist_id) serviceData['artist_id'] = artist_id; - if (sort_result) + if (sort_result != null) serviceData['sort_result'] = sort_result; // create service request. @@ -1072,7 +1072,7 @@ export class SpotifyPlusService { serviceData['artist_id'] = artist_id; if (market) serviceData['market'] = market; - if (sort_result) + if (sort_result != null) serviceData['sort_result'] = sort_result; // create service request. @@ -1151,7 +1151,7 @@ export class SpotifyPlusService { serviceData['limit'] = limit; if (limit_total) serviceData['limit_total'] = limit_total; - if (sort_result) + if (sort_result != null) serviceData['sort_result'] = sort_result; // create service request. @@ -1387,7 +1387,7 @@ export class SpotifyPlusService { serviceData['offset'] = offset; if (limit_total) serviceData['limit_total'] = limit_total; - if (sort_result) + if (sort_result != null) serviceData['sort_result'] = sort_result; // create service request. @@ -1462,7 +1462,7 @@ export class SpotifyPlusService { serviceData['country'] = country; if (locale) serviceData['locale'] = locale; - if (refresh) + if (refresh != null) serviceData['refresh'] = refresh; // create service request. @@ -1546,7 +1546,7 @@ export class SpotifyPlusService { serviceData['country'] = country; if (limit_total) serviceData['limit_total'] = limit_total; - if (sort_result) + if (sort_result != null) serviceData['sort_result'] = sort_result; // create service request. @@ -1771,7 +1771,7 @@ export class SpotifyPlusService { serviceData['offset'] = offset; if (limit_total) serviceData['limit_total'] = limit_total; - if (sort_result) + if (sort_result != null) serviceData['sort_result'] = sort_result; // create service request. @@ -2026,7 +2026,7 @@ export class SpotifyPlusService { serviceData['offset'] = offset; if (limit_total) serviceData['limit_total'] = limit_total; - if (sort_result) + if (sort_result != null) serviceData['sort_result'] = sort_result; // create service request. @@ -2199,13 +2199,13 @@ export class SpotifyPlusService { }; // update service data parameters (with optional parameters). - if (verify_user_context) + if (verify_user_context != null) serviceData['verify_user_context'] = verify_user_context; - if (verify_timeout) + if (verify_timeout != null) serviceData['verify_timeout'] = verify_timeout; - if (refresh_device_list) + if (refresh_device_list != null) serviceData['refresh_device_list'] = refresh_device_list; - if (activate_device) + if (activate_device != null) serviceData['activate_device'] = activate_device; if (delay) serviceData['delay'] = delay; @@ -2284,9 +2284,9 @@ export class SpotifyPlusService { }; // update service data parameters (with optional parameters). - if (refresh) + if (refresh != null) serviceData['refresh'] = refresh; - if (sort_result) + if (sort_result != null) serviceData['sort_result'] = sort_result; // create service request. @@ -2455,9 +2455,9 @@ export class SpotifyPlusService { serviceData['offset'] = offset; if (limit_total) serviceData['limit_total'] = limit_total; - if (sort_result) + if (sort_result != null) serviceData['sort_result'] = sort_result; - if (exclude_audiobooks) + if (exclude_audiobooks != null) serviceData['exclude_audiobooks'] = exclude_audiobooks; // create service request. @@ -2605,7 +2605,7 @@ export class SpotifyPlusService { serviceData['market'] = market; if (limit_total) serviceData['limit_total'] = limit_total; - if (sort_result) + if (sort_result != null) serviceData['sort_result'] = sort_result; // create service request. From 67b3c50020f8de7d5fd4d552faf4e9f17f959f69 Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Mon, 9 Dec 2024 14:13:05 -0600 Subject: [PATCH 07/17] [ 1.0.17 ] * This release requires the SpotifyPlus Integration v1.0.69+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. * Modified the media list items' text color to use the `--spc-medialist-items-color` variable (defaults to `white`) instead of the `--secondary-text-color`, as well as the title and sub-title background gradient. Media list item text was difficult to read using certain themes. * Disabled `Categories` section by default when adding instance from card picker. Spotify Web API functionality was deprecated unexpectedly (and without prior notice!) by the Spotify Development Team. * Updated underlying `turn_on` service to first check if the previously selected source is active or not; if so, then play is resumed immediately; if not, then a `source_select` is performed to activate the selected source. This result in a faster time to play when powering on the media player. * Updated various underlying `SpotifyClient` methods to discard favorites that do not contain a valid URI value. Sometimes the Spotify Web API returns favorite items with no information, which causes exceptions in the card while trying to display them! The following methods were updated: `GetAlbumFavorites`, `GetEpisodeFavorites`, `GetShowFavorites`, `GetTrackFavorites`. --- CHANGELOG.md | 8 ++++++++ src/card.ts | 2 +- src/components/media-browser-icons.ts | 9 +++++---- src/constants.ts | 2 +- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00c33da..95bf790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.17 ] - 2024/12/09 + + * This release requires the SpotifyPlus Integration v1.0.69+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. + * Modified the media list items' text color to use the `--spc-medialist-items-color` variable (defaults to `white`) instead of the `--secondary-text-color`, as well as the title and sub-title background gradient. Media list item text was difficult to read using certain themes. + * Disabled `Categories` section by default when adding instance from card picker. Spotify Web API functionality was deprecated unexpectedly (and without prior notice!) by the Spotify Development Team. + * Updated underlying `turn_on` service to first check if the previously selected source is active or not; if so, then play is resumed immediately; if not, then a `source_select` is performed to activate the selected source. This result in a faster time to play when powering on the media player. + * Updated various underlying `SpotifyClient` methods to discard favorites that do not contain a valid URI value. Sometimes the Spotify Web API returns favorite items with no information, which causes exceptions in the card while trying to display them! The following methods were updated: `GetAlbumFavorites`, `GetEpisodeFavorites`, `GetShowFavorites`, `GetTrackFavorites`. + ###### [ 1.0.16 ] - 2024/12/06 * This release requires the SpotifyPlus Integration v1.0.68+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. diff --git a/src/card.ts b/src/card.ts index 6386035..3917730 100644 --- a/src/card.ts +++ b/src/card.ts @@ -910,7 +910,7 @@ export class Card extends LitElement { public static getStubConfig(): Record { return { - sections: [Section.PLAYER, Section.ALBUM_FAVORITES, Section.ARTIST_FAVORITES, Section.CATEGORYS, Section.PLAYLIST_FAVORITES, + sections: [Section.PLAYER, Section.ALBUM_FAVORITES, Section.ARTIST_FAVORITES, Section.PLAYLIST_FAVORITES, Section.RECENTS, Section.DEVICES, Section.TRACK_FAVORITES, Section.USERPRESETS, Section.AUDIOBOOK_FAVORITES, Section.SHOW_FAVORITES, Section.EPISODE_FAVORITES, Section.SEARCH_MEDIA], entity: "", diff --git a/src/components/media-browser-icons.ts b/src/components/media-browser-icons.ts index 6b126d9..bc90b65 100644 --- a/src/components/media-browser-icons.ts +++ b/src/components/media-browser-icons.ts @@ -106,10 +106,10 @@ export class MediaBrowserIcons extends MediaBrowserBase { width: 100%; line-height: 160%; bottom: 0; - background-color: rgba(var(--rgb-card-background-color), 0.733); - color: var(--secondary-text-color); + background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.6)); + color: var(--spc-medialist-items-color, white); font-weight: normal; - padding: 0 0.5rem; + padding: 0.75rem 0.5rem 0rem; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; @@ -118,7 +118,8 @@ export class MediaBrowserIcons extends MediaBrowserBase { .subtitle { font-size: 0.8rem; width: 100%; - line-height: 160%; + line-height: 120%; + padding-bottom: 0.25rem; } `, ]; diff --git a/src/constants.ts b/src/constants.ts index 2b2f4d3..f583e2a 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.16'; +export const CARD_VERSION = '1.0.17'; /** SpotifyPlus integration domain identifier. */ export const DOMAIN_SPOTIFYPLUS = 'spotifyplus'; From 6b9588b2448d48e0319e215b729664811d363f1c Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Wed, 11 Dec 2024 10:08:54 -0600 Subject: [PATCH 08/17] [ 1.0.18 ] * This release requires the SpotifyPlus Integration v1.0.69+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. * Added `playerVolumeControlsHideLevels` config option that hides volume level numbers and percentages in the volume controls area of the Player section form. Volume slider control is not affected by this setting. * Added `albumFavBrowserItemsLimit` config option that specifies the maximum number of items to be returned by the Album Favorites media browser. * Added `artistFavBrowserItemsLimit` config option that specifies the maximum number of items to be returned by the Artist Favorites media browser. * Added `audiobookFavBrowserItemsLimit` config option that specifies the maximum number of items to be returned by the Audiobook Favorites media browser. * Added `episodeFavBrowserItemsLimit` config option that specifies the maximum number of items to be returned by the Episode Favorites media browser. * Added `playlistFavBrowserItemsLimit` config option that specifies the maximum number of items to be returned by the Playlist Favorites media browser. * Added `showFavBrowserItemsLimit` config option that specifies the maximum number of items to be returned by the Show Favorites media browser. * Added `trackFavBrowserItemsLimit` config option that specifies the maximum number of items to be returned by the Track Favorites media browser. * Added "Copy X Preset JSON to Clipboard" action for all section detail displays that contain a "Copy X Preset Info to Clipboard" action. This will create a user-preset JSON format configuration entry for the selected media and copy it to the clipboard; the entry can then be pasted into the `userPresets.json` file, which will create a user preset for the media item. * Added theme variable `--spc-card-footer-background-color` to set card footer area background color; default value for the player section is vibrant color (based on cover art colors); default value for all other sections is card background color. * Added theme variable `--spc-card-footer-background-image` to set card footer area background image; default value for the player section is a gradient, which provides good contrast; default value for all other sections is card background color. * Added theme variable `--spc-card-footer-color` to set card footer icon foreground color; default value is `inherit`, which is card foreground color value. * Adjusted scrollbar colors to more closely match selected theme. --- CHANGELOG.md | 17 ++++++ src/card.ts | 6 +- src/components/album-actions.ts | 26 +++++++- src/components/artist-actions.ts | 15 ++++- src/components/audiobook-actions.ts | 15 ++++- src/components/episode-actions.ts | 26 +++++++- src/components/footer.ts | 2 +- src/components/player-body-audiobook.ts | 26 +++++++- src/components/player-body-show.ts | 26 +++++++- src/components/player-body-track.ts | 37 +++++++++++- src/components/player-volume.ts | 13 ++-- src/components/playlist-actions.ts | 15 ++++- src/components/show-actions.ts | 15 ++++- src/components/track-actions.ts | 37 +++++++++++- src/constants.ts | 1 + src/editor/album-fav-browser-editor.ts | 10 ++++ src/editor/artist-fav-browser-editor.ts | 10 ++++ src/editor/audiobook-fav-browser-editor.ts | 10 ++++ src/editor/episode-fav-browser-editor.ts | 10 ++++ src/editor/player-volume-editor.ts | 8 ++- src/editor/playlist-fav-browser-editor.ts | 10 ++++ src/editor/show-fav-browser-editor.ts | 10 ++++ src/editor/track-fav-browser-editor.ts | 10 ++++ src/sections/album-fav-browser.ts | 2 +- src/sections/artist-fav-browser.ts | 2 +- src/sections/audiobook-fav-browser.ts | 2 +- src/sections/episode-fav-browser.ts | 2 +- src/sections/playlist-fav-browser.ts | 2 +- src/sections/show-fav-browser.ts | 2 +- src/sections/track-fav-browser.ts | 2 +- src/styles/shared-styles-fav-actions.js | 2 + src/styles/shared-styles-fav-browser.js | 2 + src/styles/shared-styles-grid.js | 3 + src/styles/shared-styles-media-info.js | 2 + src/types/card-config.ts | 51 +++++++++++++++- src/types/spotifyplus/user-preset.ts | 69 +++++++++++++++++++--- 36 files changed, 452 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95bf790..3876cb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.18 ] - 2024/12/11 + + * This release requires the SpotifyPlus Integration v1.0.69+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. + * Added `playerVolumeControlsHideLevels` config option that hides volume level numbers and percentages in the volume controls area of the Player section form. Volume slider control is not affected by this setting. + * Added `albumFavBrowserItemsLimit` config option that specifies the maximum number of items to be returned by the Album Favorites media browser. + * Added `artistFavBrowserItemsLimit` config option that specifies the maximum number of items to be returned by the Artist Favorites media browser. + * Added `audiobookFavBrowserItemsLimit` config option that specifies the maximum number of items to be returned by the Audiobook Favorites media browser. + * Added `episodeFavBrowserItemsLimit` config option that specifies the maximum number of items to be returned by the Episode Favorites media browser. + * Added `playlistFavBrowserItemsLimit` config option that specifies the maximum number of items to be returned by the Playlist Favorites media browser. + * Added `showFavBrowserItemsLimit` config option that specifies the maximum number of items to be returned by the Show Favorites media browser. + * Added `trackFavBrowserItemsLimit` config option that specifies the maximum number of items to be returned by the Track Favorites media browser. + * Added "Copy X Preset JSON to Clipboard" action for all section detail displays that contain a "Copy X Preset Info to Clipboard" action. This will create a user-preset JSON format configuration entry for the selected media and copy it to the clipboard; the entry can then be pasted into the `userPresets.json` file, which will create a user preset for the media item. + * Added theme variable `--spc-card-footer-background-color` to set card footer area background color; default value for the player section is vibrant color (based on cover art colors); default value for all other sections is card background color. + * Added theme variable `--spc-card-footer-background-image` to set card footer area background image; default value for the player section is a gradient, which provides good contrast; default value for all other sections is card background color. + * Added theme variable `--spc-card-footer-color` to set card footer icon foreground color; default value is `inherit`, which is card foreground color value. + * Adjusted scrollbar colors to more closely match selected theme. + ###### [ 1.0.17 ] - 2024/12/09 * This release requires the SpotifyPlus Integration v1.0.69+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. diff --git a/src/card.ts b/src/card.ts index 3917730..525b776 100644 --- a/src/card.ts +++ b/src/card.ts @@ -280,6 +280,9 @@ export class Card extends LitElement { display: flex; align-items: center; background-repeat: no-repeat; + color: var(--spc-card-footer-color, inherit); + background-color: var(--spc-card-footer-background-color, var(--spc-player-footer-bg-color, var(--card-background-color, transparent))); + background-image: var(--spc-card-footer-background-image, linear-gradient(rgba(0, 0, 0, 0.6), rgb(0, 0, 0))); } .spc-card-footer { @@ -1167,8 +1170,6 @@ export class Card extends LitElement { '--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))', }); } else { @@ -1177,6 +1178,7 @@ export class Card extends LitElement { return styleMap({ '--spc-footer-icon-size': `${footerIconSize}`, '--spc-footer-icon-button-size': `var(--spc-footer-icon-size, ${FOOTER_ICON_SIZE_DEFAULT}) + 0.75rem`, + 'background': 'unset', }); } diff --git a/src/components/album-actions.ts b/src/components/album-actions.ts index 3e2dd27..d7b0b76 100644 --- a/src/components/album-actions.ts +++ b/src/components/album-actions.ts @@ -27,9 +27,9 @@ 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 { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD, RADIO_SEARCH_KEY } from '../constants'; +import { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD, ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD, RADIO_SEARCH_KEY } from '../constants'; import { GetCopyrights } from '../types/spotifyplus/copyright'; -import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; +import { GetUserPresetConfigEntry, GetUserPresetConfigEntryJson } from '../types/spotifyplus/user-preset'; import { IAlbum } from '../types/spotifyplus/album'; import { ITrackPageSimplified } from '../types/spotifyplus/track-page-simplified'; @@ -38,6 +38,7 @@ import { ITrackPageSimplified } from '../types/spotifyplus/track-page-simplified */ enum Actions { AlbumCopyPresetToClipboard = "AlbumCopyPresetToClipboard", + AlbumCopyPresetJsonToClipboard = "AlbumCopyPresetJsonToClipboard", AlbumCopyUriToClipboard = "AlbumCopyUriToClipboard", AlbumFavoriteAdd = "AlbumFavoriteAdd", AlbumFavoriteRemove = "AlbumFavoriteRemove", @@ -47,6 +48,7 @@ enum Actions { AlbumSearchRadio = "AlbumSearchRadio", AlbumShowTracks = "AlbumShowTracks", ArtistCopyPresetToClipboard = "ArtistCopyPresetToClipboard", + ArtistCopyPresetJsonToClipboard = "ArtistCopyPresetJsonToClipboard", ArtistCopyUriToClipboard = "ArtistCopyUriToClipboard", ArtistFavoriteAdd = "ArtistFavoriteAdd", ArtistFavoriteRemove = "ArtistFavoriteRemove", @@ -186,6 +188,10 @@ class AlbumActions extends FavActionsBase {
Copy Album Preset Info to Clipboard
+ this.onClickAction(Actions.AlbumCopyPresetJsonToClipboard)}> + +
Copy Album Preset JSON to Clipboard
+
`; @@ -241,6 +247,10 @@ class AlbumActions extends FavActionsBase {
Copy Artist Preset Info to Clipboard
+ this.onClickAction(Actions.ArtistCopyPresetJsonToClipboard)}> + +
Copy Artist Preset JSON to Clipboard
+
`; @@ -377,6 +387,12 @@ class AlbumActions extends FavActionsBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.AlbumCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.mediaItem, this.mediaItem.artists[0].name)); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.AlbumCopyUriToClipboard) { copyTextToClipboard(this.mediaItem.uri); @@ -398,6 +414,12 @@ class AlbumActions extends FavActionsBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.ArtistCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.mediaItem.artists[0])); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.ArtistCopyUriToClipboard) { copyTextToClipboard(this.mediaItem.artists[0].uri); diff --git a/src/components/artist-actions.ts b/src/components/artist-actions.ts index 3740715..c586d9c 100644 --- a/src/components/artist-actions.ts +++ b/src/components/artist-actions.ts @@ -30,8 +30,8 @@ import { SearchMediaTypes } from '../types/search-media-types'; import { SearchMediaEvent } from '../events/search-media'; import { openWindowNewTab } from '../utils/media-browser-utils'; import { unescapeHtml } from '../utils/utils'; -import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; -import { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD } from '../constants'; +import { GetUserPresetConfigEntry, GetUserPresetConfigEntryJson } from '../types/spotifyplus/user-preset'; +import { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD, ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD } from '../constants'; import { IArtist, GetGenres } from '../types/spotifyplus/artist'; import { IArtistInfo } from '../types/spotifyplus/artist-info'; @@ -46,6 +46,7 @@ const debuglog = Debug(DEBUG_APP_NAME + ":artist-actions"); enum Actions { ArtistAlbumsUpdate = "ArtistAlbumsUpdate", ArtistCopyPresetToClipboard = "ArtistCopyPresetToClipboard", + ArtistCopyPresetJsonToClipboard = "ArtistCopyPresetJsonToClipboard", ArtistCopyUriToClipboard = "ArtistCopyUriToClipboard", ArtistGetInfo = "ArtistGetInfo", ArtistFavoriteAdd = "ArtistFavoriteAdd", @@ -181,6 +182,10 @@ class ArtistActions extends FavActionsBase {
Copy Artist Preset Info to Clipboard
+ this.onClickAction(Actions.ArtistCopyPresetJsonToClipboard)}> + +
Copy Artist Preset JSON to Clipboard
+
`; @@ -321,6 +326,12 @@ class ArtistActions extends FavActionsBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.ArtistCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.mediaItem)); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.ArtistCopyUriToClipboard) { copyTextToClipboard(this.mediaItem.uri); diff --git a/src/components/audiobook-actions.ts b/src/components/audiobook-actions.ts index e32d2c8..2c58503 100644 --- a/src/components/audiobook-actions.ts +++ b/src/components/audiobook-actions.ts @@ -24,10 +24,10 @@ 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 { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD } from '../constants'; +import { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD, ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD } from '../constants'; import { GetCopyrights } from '../types/spotifyplus/copyright'; import { GetResumeInfo } from '../types/spotifyplus/resume-point'; -import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; +import { GetUserPresetConfigEntry, GetUserPresetConfigEntryJson } from '../types/spotifyplus/user-preset'; import { IAudiobookSimplified, GetAudiobookNarrators, GetAudiobookAuthors } from '../types/spotifyplus/audiobook-simplified'; import { IChapterPageSimplified } from '../types/spotifyplus/chapter-page-simplified'; @@ -36,6 +36,7 @@ import { IChapterPageSimplified } from '../types/spotifyplus/chapter-page-simpli */ enum Actions { AudiobookCopyPresetToClipboard = "AudiobookCopyPresetToClipboard", + AudiobookCopyPresetJsonToClipboard = "AudiobookCopyPresetJsonToClipboard", AudiobookCopyUriToClipboard = "AudiobookCopyUriToClipboard", AudiobookFavoriteAdd = "AudiobookFavoriteAdd", AudiobookFavoriteRemove = "AudiobookFavoriteRemove", @@ -135,6 +136,10 @@ class AudiobookActions extends FavActionsBase {
Copy Audiobook Preset Info to Clipboard
+ this.onClickAction(Actions.AudiobookCopyPresetJsonToClipboard)}> + +
Copy Audiobook Preset JSON to Clipboard
+
`; @@ -269,6 +274,12 @@ class AudiobookActions extends FavActionsBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.AudiobookCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.mediaItem, GetAudiobookAuthors(this.mediaItem, ", "))); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.AudiobookCopyUriToClipboard) { copyTextToClipboard(this.mediaItem.uri || ""); diff --git a/src/components/episode-actions.ts b/src/components/episode-actions.ts index 26dc265..8db8841 100644 --- a/src/components/episode-actions.ts +++ b/src/components/episode-actions.ts @@ -23,8 +23,8 @@ 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 { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD } from '../constants'; -import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; +import { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD, ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD } from '../constants'; +import { GetUserPresetConfigEntry, GetUserPresetConfigEntryJson } from '../types/spotifyplus/user-preset'; import { IEpisode, isEpisodeObject } from '../types/spotifyplus/episode'; import { IEpisodeSimplified } from '../types/spotifyplus/episode-simplified'; @@ -33,12 +33,14 @@ import { IEpisodeSimplified } from '../types/spotifyplus/episode-simplified'; */ enum Actions { EpisodeCopyPresetToClipboard = "EpisodeCopyPresetToClipboard", + EpisodeCopyPresetJsonToClipboard = "EpisodeCopyPresetJsonToClipboard", EpisodeCopyUriToClipboard = "EpisodeCopyUriToClipboard", EpisodeFavoriteAdd = "EpisodeFavoriteAdd", EpisodeFavoriteRemove = "EpisodeFavoriteRemove", EpisodeFavoriteUpdate = "EpisodeFavoriteUpdate", EpisodeUpdate = "EpisodeUpdate", ShowCopyPresetToClipboard = "ShowCopyPresetToClipboard", + ShowCopyPresetJsonToClipboard = "ShowCopyPresetJsonToClipboard", ShowCopyUriToClipboard = "ShowCopyUriToClipboard", ShowFavoriteAdd = "ShowFavoriteAdd", ShowFavoriteRemove = "ShowFavoriteRemove", @@ -166,6 +168,10 @@ class EpisodeActions extends FavActionsBase {
Copy Show Preset Info to Clipboard
+ this.onClickAction(Actions.ShowCopyPresetJsonToClipboard)}> + +
Copy Show Preset JSON to Clipboard
+
`; @@ -183,6 +189,10 @@ class EpisodeActions extends FavActionsBase {
Copy Episode Preset Info to Clipboard
+ this.onClickAction(Actions.EpisodeCopyPresetJsonToClipboard)}> + +
Copy Episode Preset JSON to Clipboard
+
`; @@ -296,6 +306,12 @@ class EpisodeActions extends FavActionsBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.EpisodeCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.episode, this.episode?.show.name)); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.EpisodeCopyUriToClipboard) { copyTextToClipboard(this.episode?.uri || ""); @@ -307,6 +323,12 @@ class EpisodeActions extends FavActionsBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.ShowCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.episode?.show, "Podcast")); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.ShowCopyUriToClipboard) { copyTextToClipboard(this.episode?.show.uri || ""); diff --git a/src/components/footer.ts b/src/components/footer.ts index 2251250..68f23a7 100644 --- a/src/components/footer.ts +++ b/src/components/footer.ts @@ -68,7 +68,7 @@ export class Footer extends LitElement { > this.onSectionClick(Section.CATEGORYS)} selected=${this.getSectionSelected(Section.CATEGORYS)} hide=${this.getSectionEnabled(Section.CATEGORYS)} diff --git a/src/components/player-body-audiobook.ts b/src/components/player-body-audiobook.ts index 3f89982..385b4df 100644 --- a/src/components/player-body-audiobook.ts +++ b/src/components/player-body-audiobook.ts @@ -24,9 +24,9 @@ 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 { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD } from '../constants'; +import { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD, ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD } from '../constants'; import { GetAudiobookAuthors, GetAudiobookNarrators } from '../types/spotifyplus/audiobook-simplified'; -import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; +import { GetUserPresetConfigEntry, GetUserPresetConfigEntryJson } from '../types/spotifyplus/user-preset'; import { IChapter } from '../types/spotifyplus/chapter'; /** @@ -34,6 +34,7 @@ import { IChapter } from '../types/spotifyplus/chapter'; */ enum Actions { AudiobookCopyPresetToClipboard = "AudiobookCopyPresetToClipboard", + AudiobookCopyPresetJsonToClipboard = "AudiobookCopyPresetJsonToClipboard", AudiobookCopyUriToClipboard = "AudiobookCopyUriToClipboard", AudiobookFavoriteAdd = "AudiobookFavoriteAdd", AudiobookFavoriteRemove = "AudiobookFavoriteRemove", @@ -41,6 +42,7 @@ enum Actions { AudiobookSearchAuthor = "AudiobookSearchAuthor", AudiobookSearchNarrator = "AudiobookSearchNarrator", ChapterCopyPresetToClipboard = "ChapterCopyPresetToClipboard", + ChapterCopyPresetJsonToClipboard = "ChapterCopyPresetJsonToClipboard", ChapterCopyUriToClipboard = "ChapterCopyUriToClipboard", ChapterFavoriteAdd = "ChapterFavoriteAdd", ChapterFavoriteRemove = "ChapterFavoriteRemove", @@ -158,6 +160,10 @@ class PlayerBodyAudiobook extends PlayerBodyBase {
Copy Audiobook Preset Info to Clipboard
+ this.onClickAction(Actions.AudiobookCopyPresetJsonToClipboard)}> + +
Copy Audiobook Preset JSON to Clipboard
+
`; @@ -175,6 +181,10 @@ class PlayerBodyAudiobook extends PlayerBodyBase {
Copy Chapter Preset Info to Clipboard
+ this.onClickAction(Actions.ChapterCopyPresetJsonToClipboard)}> + +
Copy Chapter Preset JSON to Clipboard
+
`; @@ -312,6 +322,12 @@ class PlayerBodyAudiobook extends PlayerBodyBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.AudiobookCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.chapter?.audiobook, GetAudiobookAuthors(this.chapter?.audiobook, ", "))); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.AudiobookCopyUriToClipboard) { copyTextToClipboard(this.chapter?.audiobook.uri || ""); @@ -333,6 +349,12 @@ class PlayerBodyAudiobook extends PlayerBodyBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.ChapterCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.chapter, this.chapter?.audiobook.name)); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.ChapterCopyUriToClipboard) { copyTextToClipboard(this.chapter?.uri || ""); diff --git a/src/components/player-body-show.ts b/src/components/player-body-show.ts index 61b1d7f..dcfb7ba 100644 --- a/src/components/player-body-show.ts +++ b/src/components/player-body-show.ts @@ -23,8 +23,8 @@ 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 { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD } from '../constants'; -import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; +import { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD, ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD } from '../constants'; +import { GetUserPresetConfigEntry, GetUserPresetConfigEntryJson } from '../types/spotifyplus/user-preset'; import { IEpisode } from '../types/spotifyplus/episode'; /** @@ -32,12 +32,14 @@ import { IEpisode } from '../types/spotifyplus/episode'; */ enum Actions { EpisodeCopyPresetToClipboard = "EpisodeCopyPresetToClipboard", + EpisodeCopyPresetJsonToClipboard = "EpisodeCopyPresetJsonToClipboard", EpisodeCopyUriToClipboard = "EpisodeCopyUriToClipboard", EpisodeFavoriteAdd = "EpisodeFavoriteAdd", EpisodeFavoriteRemove = "EpisodeFavoriteRemove", EpisodeFavoriteUpdate = "EpisodeFavoriteUpdate", GetPlayingItem = "GetPlayingItem", ShowCopyPresetToClipboard = "ShowCopyPresetToClipboard", + ShowCopyPresetJsonToClipboard = "ShowCopyPresetJsonToClipboard", ShowCopyUriToClipboard = "ShowCopyUriToClipboard", ShowFavoriteAdd = "ShowFavoriteAdd", ShowFavoriteRemove = "ShowFavoriteRemove", @@ -151,6 +153,10 @@ class PlayerBodyShow extends PlayerBodyBase {
Copy Show Preset Info to Clipboard
+ this.onClickAction(Actions.ShowCopyPresetJsonToClipboard)}> + +
Copy Show Preset JSON to Clipboard
+
`; @@ -168,6 +174,10 @@ class PlayerBodyShow extends PlayerBodyBase {
Copy Episode Preset Info to Clipboard
+ this.onClickAction(Actions.EpisodeCopyPresetJsonToClipboard)}> + +
Copy Episode Preset JSON to Clipboard
+
`; @@ -279,6 +289,12 @@ class PlayerBodyShow extends PlayerBodyBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.EpisodeCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.episode, this.episode?.show.name)); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.EpisodeCopyUriToClipboard) { copyTextToClipboard(this.episode?.uri || ""); @@ -290,6 +306,12 @@ class PlayerBodyShow extends PlayerBodyBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.ShowCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.episode?.show, "Podcast")); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.ShowCopyUriToClipboard) { copyTextToClipboard(this.episode?.show.uri || ""); diff --git a/src/components/player-body-track.ts b/src/components/player-body-track.ts index 9388f00..1207676 100644 --- a/src/components/player-body-track.ts +++ b/src/components/player-body-track.ts @@ -26,8 +26,8 @@ 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 { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD, RADIO_SEARCH_KEY } from '../constants.js'; -import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset.js'; +import { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD, ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD, RADIO_SEARCH_KEY } from '../constants.js'; +import { GetUserPresetConfigEntry, GetUserPresetConfigEntryJson } from '../types/spotifyplus/user-preset.js'; import { ITrack } from '../types/spotifyplus/track'; /** @@ -36,6 +36,7 @@ import { ITrack } from '../types/spotifyplus/track'; enum Actions { GetPlayingItem = "GetPlayingItem", AlbumCopyPresetToClipboard = "AlbumCopyPresetToClipboard", + AlbumCopyPresetJsonToClipboard = "AlbumCopyPresetJsonToClipboard", AlbumCopyUriToClipboard = "AlbumCopyUriToClipboard", AlbumFavoriteAdd = "AlbumFavoriteAdd", AlbumFavoriteRemove = "AlbumFavoriteRemove", @@ -43,6 +44,7 @@ enum Actions { AlbumSearchRadio = "AlbumSearchRadio", AlbumShowTracks = "AlbumShowTracks", ArtistCopyPresetToClipboard = "ArtistCopyPresetToClipboard", + ArtistCopyPresetJsonToClipboard = "ArtistCopyPresetJsonToClipboard", ArtistCopyUriToClipboard = "ArtistCopyUriToClipboard", ArtistFavoriteAdd = "ArtistFavoriteAdd", ArtistFavoriteRemove = "ArtistFavoriteRemove", @@ -57,6 +59,7 @@ enum Actions { ArtistShowRelatedArtists = "ArtistShowRelatedArtists", ArtistShowTopTracks = "ArtistShowTopTracks", TrackCopyPresetToClipboard = "TrackCopyPresetToClipboard", + TrackCopyPresetJsonToClipboard = "TrackCopyPresetJsonToClipboard", TrackCopyUriToClipboard = "TrackCopyUriToClipboard", TrackFavoriteAdd = "TrackFavoriteAdd", TrackFavoriteRemove = "TrackFavoriteRemove", @@ -209,6 +212,10 @@ class PlayerBodyTrack extends PlayerBodyBase {
Copy Track Preset Info to Clipboard
+ this.onClickAction(Actions.TrackCopyPresetJsonToClipboard)}> + +
Copy Track Preset JSON to Clipboard
+
`; @@ -235,6 +242,10 @@ class PlayerBodyTrack extends PlayerBodyBase {
Copy Album Preset Info to Clipboard
+ this.onClickAction(Actions.AlbumCopyPresetJsonToClipboard)}> + +
Copy Album Preset JSON to Clipboard
+
`; @@ -290,6 +301,10 @@ class PlayerBodyTrack extends PlayerBodyBase {
Copy Artist Preset Info to Clipboard
+ this.onClickAction(Actions.ArtistCopyPresetJsonToClipboard)}> + +
Copy Artist Preset JSON to Clipboard
+
`; @@ -417,6 +432,12 @@ class PlayerBodyTrack extends PlayerBodyBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.AlbumCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.track?.album, this.track?.album.artists[0].name)); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.AlbumCopyUriToClipboard) { copyTextToClipboard(this.track?.album.uri || ""); @@ -438,6 +459,12 @@ class PlayerBodyTrack extends PlayerBodyBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.ArtistCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.track?.artists[0])); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.ArtistCopyUriToClipboard) { copyTextToClipboard(this.track?.artists[0].uri || ""); @@ -494,6 +521,12 @@ class PlayerBodyTrack extends PlayerBodyBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.TrackCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.track, this.track?.artists[0].name)); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.TrackCopyUriToClipboard) { copyTextToClipboard(this.track?.uri || ""); diff --git a/src/components/player-volume.ts b/src/components/player-volume.ts index db1dd25..b50a65c 100644 --- a/src/components/player-volume.ts +++ b/src/components/player-volume.ts @@ -48,6 +48,7 @@ class Volume extends LitElement { // get volume hide configuration setting. const hideMute = this.config.playerVolumeControlsHideMute || false; + const hideLevels = this.config.playerVolumeControlsHideLevels || false; const muteIcon = this.player.isMuted() ? mdiVolumeMute : mdiVolumeHigh; // set button color based on selected option. @@ -68,11 +69,13 @@ class Volume extends LitElement { max=${maxVolume} @value-changed=${this.onVolumeValueChanged} > -
-
0%
-
${Math.round(volume)}%
-
${maxVolume}%
-
+ ${!hideLevels ? html` +
+
0%
+
${Math.round(volume)}%
+
${maxVolume}%
+
+ ` : html``}
this.onClickAction(TURN_ON)} hide=${this.hideFeature(TURN_ON)} label="Turn On" style=${this.styleIcon(colorPower)}> this.onClickAction(TURN_OFF)} hide=${this.hideFeature(TURN_OFF)} label="Turn Off"> diff --git a/src/components/playlist-actions.ts b/src/components/playlist-actions.ts index 41e401a..f87d529 100644 --- a/src/components/playlist-actions.ts +++ b/src/components/playlist-actions.ts @@ -22,9 +22,9 @@ import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; import { formatDateHHMMSSFromMilliseconds, unescapeHtml } from '../utils/utils'; import { openWindowNewTab } from '../utils/media-browser-utils'; -import { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD } from '../constants'; +import { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD, ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD } from '../constants'; import { GetPlaylistPagePlaylistTracks } from '../types/spotifyplus/playlist-page'; -import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; +import { GetUserPresetConfigEntry, GetUserPresetConfigEntryJson } from '../types/spotifyplus/user-preset'; import { IPlaylistSimplified } from '../types/spotifyplus/playlist-simplified'; import { IPlaylistTrack } from '../types/spotifyplus/playlist-track'; @@ -33,6 +33,7 @@ import { IPlaylistTrack } from '../types/spotifyplus/playlist-track'; */ enum Actions { PlaylistCopyPresetToClipboard = "PlaylistCopyPresetToClipboard", + PlaylistCopyPresetJsonToClipboard = "PlaylistCopyPresetJsonToClipboard", PlaylistCopyUriToClipboard = "PlaylistCopyUriToClipboard", PlaylistDelete = "PlaylistDelete", PlaylistFavoriteAdd = "PlaylistFavoriteAdd", @@ -132,6 +133,10 @@ class PlaylistActions extends FavActionsBase {
Copy Playlist Preset Info to Clipboard
+ this.onClickAction(Actions.PlaylistCopyPresetJsonToClipboard)}> + +
Copy Playlist Preset JSON to Clipboard
+
`; @@ -290,6 +295,12 @@ class PlaylistActions extends FavActionsBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.PlaylistCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.mediaItem)); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } // show progress indicator. diff --git a/src/components/show-actions.ts b/src/components/show-actions.ts index 2241bdc..56217f1 100644 --- a/src/components/show-actions.ts +++ b/src/components/show-actions.ts @@ -24,10 +24,10 @@ 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 { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD } from '../constants'; +import { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD, ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD } from '../constants'; import { GetCopyrights } from '../types/spotifyplus/copyright'; import { GetResumeInfo } from '../types/spotifyplus/resume-point'; -import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset'; +import { GetUserPresetConfigEntry, GetUserPresetConfigEntryJson } from '../types/spotifyplus/user-preset'; import { IShowSimplified } from '../types/spotifyplus/show-simplified'; import { IEpisodePageSimplified } from '../types/spotifyplus/episode-page-simplified'; @@ -36,6 +36,7 @@ import { IEpisodePageSimplified } from '../types/spotifyplus/episode-page-simpli */ enum Actions { ShowCopyPresetToClipboard = "ShowCopyPresetToClipboard", + ShowCopyPresetJsonToClipboard = "ShowCopyPresetJsonToClipboard", ShowCopyUriToClipboard = "ShowCopyUriToClipboard", ShowEpisodesUpdate = "ShowEpisodesUpdate", ShowFavoriteAdd = "ShowFavoriteAdd", @@ -130,6 +131,10 @@ class ShowActions extends FavActionsBase {
Copy Show Preset Info to Clipboard
+ this.onClickAction(Actions.ShowCopyPresetJsonToClipboard)}> + +
Copy Show Preset JSON to Clipboard
+
`; @@ -258,6 +263,12 @@ class ShowActions extends FavActionsBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.ShowCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.mediaItem, "Podcast")); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.ShowCopyUriToClipboard) { copyTextToClipboard(this.mediaItem.uri); diff --git a/src/components/track-actions.ts b/src/components/track-actions.ts index ef74811..6705bbf 100644 --- a/src/components/track-actions.ts +++ b/src/components/track-actions.ts @@ -27,8 +27,8 @@ 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 { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD, RADIO_SEARCH_KEY } from '../constants.js'; -import { GetUserPresetConfigEntry } from '../types/spotifyplus/user-preset.js'; +import { ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD, ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD, RADIO_SEARCH_KEY } from '../constants.js'; +import { GetUserPresetConfigEntry, GetUserPresetConfigEntryJson } from '../types/spotifyplus/user-preset.js'; import { ITrack } from '../types/spotifyplus/track'; /** @@ -36,6 +36,7 @@ import { ITrack } from '../types/spotifyplus/track'; */ enum Actions { AlbumCopyPresetToClipboard = "AlbumCopyPresetToClipboard", + AlbumCopyPresetJsonToClipboard = "AlbumCopyPresetJsonToClipboard", AlbumCopyUriToClipboard = "AlbumCopyUriToClipboard", AlbumFavoriteAdd = "AlbumFavoriteAdd", AlbumFavoriteRemove = "AlbumFavoriteRemove", @@ -43,6 +44,7 @@ enum Actions { AlbumSearchRadio = "AlbumSearchRadio", AlbumShowTracks = "AlbumShowTracks", ArtistCopyPresetToClipboard = "ArtistCopyPresetToClipboard", + ArtistCopyPresetJsonToClipboard = "ArtistCopyPresetJsonToClipboard", ArtistCopyUriToClipboard = "ArtistCopyUriToClipboard", ArtistFavoriteAdd = "ArtistFavoriteAdd", ArtistFavoriteRemove = "ArtistFavoriteRemove", @@ -57,6 +59,7 @@ enum Actions { ArtistShowRelatedArtists = "ArtistShowRelatedArtists", ArtistShowTopTracks = "ArtistShowTopTracks", TrackCopyPresetToClipboard = "TrackCopyPresetToClipboard", + TrackCopyPresetJsonToClipboard = "TrackCopyPresetJsonToClipboard", TrackCopyUriToClipboard = "TrackCopyUriToClipboard", TrackFavoriteAdd = "TrackFavoriteAdd", TrackFavoriteRemove = "TrackFavoriteRemove", @@ -228,6 +231,10 @@ class TrackActions extends FavActionsBase {
Copy Track Preset Info to Clipboard
+ this.onClickAction(Actions.TrackCopyPresetJsonToClipboard)}> + +
Copy Track Preset JSON to Clipboard
+
`; @@ -254,6 +261,10 @@ class TrackActions extends FavActionsBase {
Copy Album Preset Info to Clipboard
+ this.onClickAction(Actions.AlbumCopyPresetJsonToClipboard)}> + +
Copy Album Preset JSON to Clipboard
+
`; @@ -309,6 +320,10 @@ class TrackActions extends FavActionsBase {
Copy Artist Preset Info to Clipboard
+ this.onClickAction(Actions.ArtistCopyPresetJsonToClipboard)}> + +
Copy Artist Preset JSON to Clipboard
+
`; @@ -438,6 +453,12 @@ class TrackActions extends FavActionsBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.AlbumCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.mediaItem.album, this.mediaItem.album.artists[0].name)); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.AlbumCopyUriToClipboard) { copyTextToClipboard(this.mediaItem.album.uri); @@ -459,6 +480,12 @@ class TrackActions extends FavActionsBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.ArtistCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.mediaItem.artists[0])); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.ArtistCopyUriToClipboard) { copyTextToClipboard(this.mediaItem.artists[0].uri); @@ -515,6 +542,12 @@ class TrackActions extends FavActionsBase { this.alertInfoSet(ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD); return true; + } else if (action == Actions.TrackCopyPresetJsonToClipboard) { + + copyTextToClipboard(GetUserPresetConfigEntryJson(this.mediaItem, this.mediaItem.artists[0].name)); + this.alertInfoSet(ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD); + return true; + } else if (action == Actions.TrackCopyUriToClipboard) { copyTextToClipboard(this.mediaItem.uri); diff --git a/src/constants.ts b/src/constants.ts index f583e2a..e5bbf17 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -59,3 +59,4 @@ export const listStyle = css` `; export const ALERT_INFO_PRESET_COPIED_TO_CLIPBOARD = "Preset info copied to clipboard; please edit the card configuration (via show code editor) and paste copied text under the \"userPresets:\" key." +export const ALERT_INFO_PRESET_JSON_COPIED_TO_CLIPBOARD = "Preset JSON copied to clipboard; please edit the userPresets.json file and paste the copied text at the desired position. Be sure to remove ending comma if last (or only) entry in the file." diff --git a/src/editor/album-fav-browser-editor.ts b/src/editor/album-fav-browser-editor.ts index 1b10879..36a7c38 100644 --- a/src/editor/album-fav-browser-editor.ts +++ b/src/editor/album-fav-browser-editor.ts @@ -21,6 +21,16 @@ const CONFIG_SETTINGS_SCHEMA = [ required: false, type: 'string', }, + { + name: 'albumFavBrowserItemsLimit', + label: 'Maximum # of favorite items to return', + help: '1000 max, 200 default', + required: false, + type: 'integer', + default: 200, + valueMin: 1, + valueMax: 1000, + }, { name: 'albumFavBrowserItemsPerRow', label: '# of items to display per row', diff --git a/src/editor/artist-fav-browser-editor.ts b/src/editor/artist-fav-browser-editor.ts index 32b8ca1..c5f4709 100644 --- a/src/editor/artist-fav-browser-editor.ts +++ b/src/editor/artist-fav-browser-editor.ts @@ -21,6 +21,16 @@ const CONFIG_SETTINGS_SCHEMA = [ required: false, type: 'string', }, + { + name: 'artistFavBrowserItemsLimit', + label: 'Maximum # of favorite items to return', + help: '1000 max, 200 default', + required: false, + type: 'integer', + default: 200, + valueMin: 1, + valueMax: 1000, + }, { name: 'artistFavBrowserItemsPerRow', label: '# of items to display per row', diff --git a/src/editor/audiobook-fav-browser-editor.ts b/src/editor/audiobook-fav-browser-editor.ts index 98b0d84..eceffbe 100644 --- a/src/editor/audiobook-fav-browser-editor.ts +++ b/src/editor/audiobook-fav-browser-editor.ts @@ -21,6 +21,16 @@ const CONFIG_SETTINGS_SCHEMA = [ required: false, type: 'string', }, + { + name: 'audiobookFavBrowserItemsLimit', + label: 'Maximum # of favorite items to return', + help: '1000 max, 200 default', + required: false, + type: 'integer', + default: 200, + valueMin: 1, + valueMax: 1000, + }, { name: 'audiobookFavBrowserItemsPerRow', label: '# of items to display per row', diff --git a/src/editor/episode-fav-browser-editor.ts b/src/editor/episode-fav-browser-editor.ts index 796f331..008240e 100644 --- a/src/editor/episode-fav-browser-editor.ts +++ b/src/editor/episode-fav-browser-editor.ts @@ -21,6 +21,16 @@ const CONFIG_SETTINGS_SCHEMA = [ required: false, type: 'string', }, + { + name: 'episodeFavBrowserItemsLimit', + label: 'Maximum # of favorite items to return', + help: '1000 max, 200 default', + required: false, + type: 'integer', + default: 200, + valueMin: 1, + valueMax: 1000, + }, { name: 'episodeFavBrowserItemsPerRow', label: '# of items to display per row', diff --git a/src/editor/player-volume-editor.ts b/src/editor/player-volume-editor.ts index 959b093..f6f0367 100644 --- a/src/editor/player-volume-editor.ts +++ b/src/editor/player-volume-editor.ts @@ -30,7 +30,13 @@ const CONFIG_SETTINGS_SCHEMA = [ }, { name: 'playerVolumeControlsHideSlider', - label: 'Hide volume slider in the volume controls area', + label: 'Hide volume slider and levels in the volume controls area', + required: false, + selector: { boolean: {} }, + }, + { + name: 'playerVolumeControlsHideLevels', + label: "Hide volume level numbers / %'s in the volume controls area", required: false, selector: { boolean: {} }, }, diff --git a/src/editor/playlist-fav-browser-editor.ts b/src/editor/playlist-fav-browser-editor.ts index 4ba11ec..fd0352f 100644 --- a/src/editor/playlist-fav-browser-editor.ts +++ b/src/editor/playlist-fav-browser-editor.ts @@ -21,6 +21,16 @@ const CONFIG_SETTINGS_SCHEMA = [ required: false, type: 'string', }, + { + name: 'playlistFavBrowserItemsLimit', + label: 'Maximum # of favorite items to return', + help: '1000 max, 200 default', + required: false, + type: 'integer', + default: 200, + valueMin: 1, + valueMax: 1000, + }, { name: 'playlistFavBrowserItemsPerRow', label: '# of items to display per row', diff --git a/src/editor/show-fav-browser-editor.ts b/src/editor/show-fav-browser-editor.ts index b8efca3..582e081 100644 --- a/src/editor/show-fav-browser-editor.ts +++ b/src/editor/show-fav-browser-editor.ts @@ -21,6 +21,16 @@ const CONFIG_SETTINGS_SCHEMA = [ required: false, type: 'string', }, + { + name: 'showFavBrowserItemsLimit', + label: 'Maximum # of favorite items to return', + help: '1000 max, 200 default', + required: false, + type: 'integer', + default: 200, + valueMin: 1, + valueMax: 1000, + }, { name: 'showFavBrowserItemsPerRow', label: '# of items to display per row', diff --git a/src/editor/track-fav-browser-editor.ts b/src/editor/track-fav-browser-editor.ts index 08b707c..ffb0228 100644 --- a/src/editor/track-fav-browser-editor.ts +++ b/src/editor/track-fav-browser-editor.ts @@ -21,6 +21,16 @@ const CONFIG_SETTINGS_SCHEMA = [ required: false, type: 'string', }, + { + name: 'trackFavBrowserItemsLimit', + label: 'Maximum # of favorite items to return', + help: '1000 max, 200 default', + required: false, + type: 'integer', + default: 200, + valueMin: 1, + valueMax: 1000, + }, { name: 'trackFavBrowserItemsPerRow', label: '# of items to display per row', diff --git a/src/sections/album-fav-browser.ts b/src/sections/album-fav-browser.ts index 84c383a..26596ab 100644 --- a/src/sections/album-fav-browser.ts +++ b/src/sections/album-fav-browser.ts @@ -116,7 +116,7 @@ export class AlbumFavBrowser extends FavBrowserBase { const promiseUpdateMediaList = new Promise((resolve, reject) => { // set service parameters. - const limitTotal = this.LIMIT_TOTAL_MAX; + const limitTotal = this.config.albumFavBrowserItemsLimit || this.LIMIT_TOTAL_MAX; const sortResult = this.config.albumFavBrowserItemsSortTitle || false; const market = null; diff --git a/src/sections/artist-fav-browser.ts b/src/sections/artist-fav-browser.ts index bf538b6..16e00c3 100644 --- a/src/sections/artist-fav-browser.ts +++ b/src/sections/artist-fav-browser.ts @@ -115,7 +115,7 @@ export class ArtistFavBrowser extends FavBrowserBase { const promiseUpdateMediaList = new Promise((resolve, reject) => { // set service parameters. - const limitTotal = this.LIMIT_TOTAL_MAX; + const limitTotal = this.config.artistFavBrowserItemsLimit || this.LIMIT_TOTAL_MAX; const sortResult = this.config.artistFavBrowserItemsSortTitle || false; // call the service to retrieve the media list. diff --git a/src/sections/audiobook-fav-browser.ts b/src/sections/audiobook-fav-browser.ts index 15a1ec5..652edeb 100644 --- a/src/sections/audiobook-fav-browser.ts +++ b/src/sections/audiobook-fav-browser.ts @@ -115,7 +115,7 @@ export class AudiobookFavBrowser extends FavBrowserBase { const promiseUpdateMediaList = new Promise((resolve, reject) => { // set service parameters. - const limitTotal = this.LIMIT_TOTAL_MAX; + const limitTotal = this.config.audiobookFavBrowserItemsLimit || this.LIMIT_TOTAL_MAX; const sortResult = this.config.audiobookFavBrowserItemsSortTitle || false; // call the service to retrieve the media list. diff --git a/src/sections/episode-fav-browser.ts b/src/sections/episode-fav-browser.ts index e0c5768..d4d5e7c 100644 --- a/src/sections/episode-fav-browser.ts +++ b/src/sections/episode-fav-browser.ts @@ -116,7 +116,7 @@ export class EpisodeFavBrowser extends FavBrowserBase { const promiseUpdateMediaList = new Promise((resolve, reject) => { // set service parameters. - const limitTotal = this.LIMIT_TOTAL_MAX; // max # of items to return + const limitTotal = this.config.episodeFavBrowserItemsLimit || this.LIMIT_TOTAL_MAX; const sortResult = this.config.episodeFavBrowserItemsSortTitle || false; // call the service to retrieve the media list. diff --git a/src/sections/playlist-fav-browser.ts b/src/sections/playlist-fav-browser.ts index 36ee64c..44c546e 100644 --- a/src/sections/playlist-fav-browser.ts +++ b/src/sections/playlist-fav-browser.ts @@ -115,7 +115,7 @@ export class PlaylistFavBrowser extends FavBrowserBase { const promiseUpdateMediaList = new Promise((resolve, reject) => { // set service parameters. - const limitTotal = this.LIMIT_TOTAL_MAX; // max # of items to return + const limitTotal = this.config.playlistFavBrowserItemsLimit || this.LIMIT_TOTAL_MAX; const sortResult = this.config.playlistFavBrowserItemsSortTitle || false; // call the service to retrieve the media list. diff --git a/src/sections/show-fav-browser.ts b/src/sections/show-fav-browser.ts index 3ec8b8c..8bd8df6 100644 --- a/src/sections/show-fav-browser.ts +++ b/src/sections/show-fav-browser.ts @@ -116,7 +116,7 @@ export class ShowFavBrowser extends FavBrowserBase { const promiseUpdateMediaList = new Promise((resolve, reject) => { // set service parameters. - const limitTotal = this.LIMIT_TOTAL_MAX; // max # of items to return + const limitTotal = this.config.showFavBrowserItemsLimit || this.LIMIT_TOTAL_MAX; const sortResult = this.config.showFavBrowserItemsSortTitle || false; const excludeAudiobooks = true; diff --git a/src/sections/track-fav-browser.ts b/src/sections/track-fav-browser.ts index 19e24a5..d4ae9b4 100644 --- a/src/sections/track-fav-browser.ts +++ b/src/sections/track-fav-browser.ts @@ -116,7 +116,7 @@ export class TrackFavBrowser extends FavBrowserBase { const promiseUpdateMediaList = new Promise((resolve, reject) => { // set service parameters. - const limitTotal = this.LIMIT_TOTAL_MAX; // max # of items to return + const limitTotal = this.config.trackFavBrowserItemsLimit || this.LIMIT_TOTAL_MAX; const sortResult = this.config.trackFavBrowserItemsSortTitle || false; const market = null; // market code. diff --git a/src/styles/shared-styles-fav-actions.js b/src/styles/shared-styles-fav-actions.js index 2b2fb4d..bfb8b6a 100644 --- a/src/styles/shared-styles-fav-actions.js +++ b/src/styles/shared-styles-fav-actions.js @@ -23,6 +23,8 @@ export const sharedStylesFavActions = css` height: inherit; overflow-y: auto; overflow-x: clip; + scrollbar-color: var(--primary-text-color) var(--secondary-background-color); + scrollbar-width: inherit; color: white; } diff --git a/src/styles/shared-styles-fav-browser.js b/src/styles/shared-styles-fav-browser.js index 3e85190..ccb74fa 100644 --- a/src/styles/shared-styles-fav-browser.js +++ b/src/styles/shared-styles-fav-browser.js @@ -78,6 +78,8 @@ export const sharedStylesFavBrowser = css` flex: 3; max-height: 100vh; overflow-y: auto; + scrollbar-color: var(--primary-text-color) var(--secondary-background-color); + scrollbar-width: inherit; } .media-browser-list { diff --git a/src/styles/shared-styles-grid.js b/src/styles/shared-styles-grid.js index bd8ee93..9a44b04 100644 --- a/src/styles/shared-styles-grid.js +++ b/src/styles/shared-styles-grid.js @@ -17,6 +17,8 @@ export const sharedStylesGrid = css` /* style grid container */ .grid-container-scrollable { overflow-y: auto; + scrollbar-color: var(--primary-text-color) var(--secondary-background-color); + scrollbar-width: inherit; max-height: 100vh; margin: 0.25rem; align-self: stretch @@ -48,6 +50,7 @@ export const sharedStylesGrid = css` /* scrolling text bleeds through if you set BG-COLOR to transparent! */ .grid-header { background-color: var(--card-background-color); + color: var(--accent-color); position: sticky; top: 0; z-index: 1; diff --git a/src/styles/shared-styles-media-info.js b/src/styles/shared-styles-media-info.js index 651249e..65373a9 100644 --- a/src/styles/shared-styles-media-info.js +++ b/src/styles/shared-styles-media-info.js @@ -31,6 +31,8 @@ export const sharedStylesMediaInfo = css` .media-info-description { overflow-y: auto; + scrollbar-color: var(--primary-text-color) var(--secondary-background-color); + scrollbar-width: inherit; display: block; height: inherit; padding-top: 10px; diff --git a/src/types/card-config.ts b/src/types/card-config.ts index 9da2725..b63dc77 100644 --- a/src/types/card-config.ts +++ b/src/types/card-config.ts @@ -85,6 +85,12 @@ export interface CardConfig extends LovelaceCardConfig { */ albumFavBrowserItemsHideSubTitle?: boolean; + /** + * Maximum number of items to be returned by the Album Favorites media browser. + * Default is 200. + */ + albumFavBrowserItemsLimit?: number; + /** * True to sort displayed Album Favorites media browser item titles by name; * Otherwise, False to display in the order returned from the Spotify Web API. @@ -125,6 +131,12 @@ export interface CardConfig extends LovelaceCardConfig { */ artistFavBrowserItemsHideSubTitle?: boolean; + /** + * Maximum number of items to be returned by the Artist Favorites media browser. + * Default is 200. + */ + artistFavBrowserItemsLimit?: number; + /** * True to sort displayed Artist Favorites media browser item titles by name; * Otherwise, False to display in the order returned from the Spotify Web API. @@ -165,6 +177,12 @@ export interface CardConfig extends LovelaceCardConfig { */ audiobookFavBrowserItemsHideSubTitle?: boolean; + /** + * Maximum number of items to be returned by the Audiobook Favorites media browser. + * Default is 200. + */ + audiobookFavBrowserItemsLimit?: number; + /** * True to sort displayed Audiobook Favorites media browser item titles by name; * Otherwise, False to display in the order returned from the Spotify Web API. @@ -278,6 +296,12 @@ export interface CardConfig extends LovelaceCardConfig { */ episodeFavBrowserItemsHideSubTitle?: boolean; + /** + * Maximum number of items to be returned by the Episode Favorites media browser. + * Default is 200. + */ + episodeFavBrowserItemsLimit?: number; + /** * True to sort displayed Episode Favorites media browser item titles by name; * Otherwise, False to display in the order returned from the Spotify Web API. @@ -394,6 +418,13 @@ export interface CardConfig extends LovelaceCardConfig { */ playerControlsIconSize?: string; + /** + * Hide volume level numbers and percentages in the volume controls area of the Player + * section form. Volume slider control is not affected by this setting. + * Default is false. + */ + playerVolumeControlsHideLevels?: boolean; + /** * Hide mute button in the volume controls area of the Player section form. * Default is false. @@ -407,7 +438,7 @@ export interface CardConfig extends LovelaceCardConfig { playerVolumeControlsHidePower?: boolean; /** - * Hide volume slider in the volume controls area of the Player section form. + * Hide volume slider and levels in the volume controls area of the Player section form. * Default is false. */ playerVolumeControlsHideSlider?: boolean; @@ -445,6 +476,12 @@ export interface CardConfig extends LovelaceCardConfig { */ playlistFavBrowserItemsHideSubTitle?: boolean; + /** + * Maximum number of items to be returned by the Playlist Favorites media browser. + * Default is 200. + */ + playlistFavBrowserItemsLimit?: number; + /** * True to sort displayed Playlist Favorites media browser item titles by name; * Otherwise, False to display in the order returned from the Spotify Web API. @@ -580,6 +617,12 @@ export interface CardConfig extends LovelaceCardConfig { */ showFavBrowserItemsHideSubTitle?: boolean; + /** + * Maximum number of items to be returned by the Show Favorites media browser. + * Default is 200. + */ + showFavBrowserItemsLimit?: number; + /** * True to sort displayed Show Favorites media browser item titles by name; * Otherwise, False to display in the order returned from the Spotify Web API. @@ -620,6 +663,12 @@ export interface CardConfig extends LovelaceCardConfig { */ trackFavBrowserItemsHideSubTitle?: boolean; + /** + * Maximum number of items to be returned by the Track Favorites media browser. + * Default is 200. + */ + trackFavBrowserItemsLimit?: number; + /** * True to sort displayed Track Favorites media browser item titles by name; * Otherwise, False to display in the order returned from the Spotify Web API. diff --git a/src/types/spotifyplus/user-preset.ts b/src/types/spotifyplus/user-preset.ts index e7728fc..ca00c71 100644 --- a/src/types/spotifyplus/user-preset.ts +++ b/src/types/spotifyplus/user-preset.ts @@ -57,17 +57,16 @@ export interface IUserPreset { /** -* Gets a text-representation of an `IUserPreset` object, which can then be pasted into -* the card configuration under the `userPresets:` key. -* -* @param mediaItem A media item object that contains the following properties: name, type, image_url, and uri. -* @param subTitle Value to use for the sub-title text; null value will use the mediaItem type value. -* @returns An array of `ITrack` objects that exist in the collection; otherwise, an empty array. + * Gets an `IUserPreset` object from a media item content. + * + * @param mediaItem A media item object that contains the following properties: name, type, image_url, and uri. + * @param subTitle Value to use for the sub-title text; null value will use the mediaItem type value. + * @returns An `IUserPreset` object. */ -export function GetUserPresetConfigEntry( +export function GetUserPresetObject( mediaItem: any, subTitle: string | undefined | null = null, -): string { +): IUserPreset { // create user preset object. const preset: IUserPreset = { @@ -86,6 +85,26 @@ export function GetUserPresetConfigEntry( ); } + // return to caller. + return preset; +} + +/** + * Gets a text-representation of an `IUserPreset` object, which can then be pasted into + * the card configuration under the `userPresets:` key. + * + * @param mediaItem A media item object that contains the following properties: name, type, image_url, and uri. + * @param subTitle Value to use for the sub-title text; null value will use the mediaItem type value. + * @returns A text-representation of an `IUserPreset` object. +*/ +export function GetUserPresetConfigEntry( + mediaItem: any, + subTitle: string | undefined | null = null, +): string { + + // create user preset object. + const preset = GetUserPresetObject(mediaItem, subTitle); + // create text-representation of user preset object. const CRLF = "\n"; let presetText = ""; @@ -97,6 +116,38 @@ export function GetUserPresetConfigEntry( // return to caller. return presetText; +} + +/** + * Gets a JSON-representation of an `IUserPreset` object, which can then be pasted into + * the userPresets.json file. + * + * @param mediaItem A media item object that contains the following properties: name, type, image_url, and uri. + * @param subTitle Value to use for the sub-title text; null value will use the mediaItem type value. + * @returns A JSON-representation of an `IUserPreset` object. +*/ +export function GetUserPresetConfigEntryJson( + mediaItem: any, + subTitle: string | undefined | null = null, +): string { + + // create user preset object. + const preset = GetUserPresetObject(mediaItem, subTitle); + + // create text-representation of user preset object. + const CRLF = "\n"; + let presetText = ""; + presetText += " {" + CRLF; + presetText += " \"name\": \"" + preset.name + "\"," + CRLF; + presetText += " \"subtitle\": \"" + preset.type + "\"," + CRLF; + presetText += " \"image_url\": \"" + preset.image_url + "\"," + CRLF; + presetText += " \"uri\": \"" + preset.uri + "\"," + CRLF; + presetText += " \"type\": \"" + preset.type + "\"" + CRLF; + presetText += " }," + CRLF; + + // return to caller. + return presetText; +} // the following was my attempt to automatically add the new preset to the @@ -205,4 +256,4 @@ export function GetUserPresetConfigEntry( //} -} +//} From 631224aae07417421cf585d9de3a67a37112f74b Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Sun, 15 Dec 2024 14:55:48 -0600 Subject: [PATCH 09/17] [ 1.0.19 ] * Added favorite indicator to the player section form for track, show episode, and audiobook items. The heart icon will be displayed to the right of the item name. A solid red heart indicates the item is a favorite; a transparent heart indicates the item is not a favorite. * Added "Play All Track Favorites" action to the track favorites section actions. This will get a list of the tracks saved in the current Spotify user's 'Your Library' and starts playing them, with shuffle enabled.. * Added logic to player `PREVIOUS_TRACK` control so that if more than 8 seconds have passed the currently playing track is just restarted from the beginning; otherwise, the previous track is selected if progress is past the 8 second point. --- CHANGELOG.md | 6 + src/card.ts | 2 +- src/components/media-browser-base.ts | 12 -- src/components/player-body-audiobook.ts | 14 +- src/components/player-body-show.ts | 14 +- src/components/player-body-track.ts | 14 +- src/components/player-controls.ts | 22 ++- src/components/player-header.ts | 56 +++++++- src/components/track-actions.ts | 14 ++ src/constants.ts | 2 +- src/services/spotifyplus-service.ts | 69 +++++++++ .../hass-entity-attributes-media-player.ts | 136 +++++++++++++++++- src/utils/media-browser-utils.ts | 6 +- 13 files changed, 331 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3876cb8..5a21061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.19 ] - 2024/12/15 + + * Added favorite indicator to the player section form for track, show episode, and audiobook items. The heart icon will be displayed to the right of the item name. A solid red heart indicates the item is a favorite; a transparent heart indicates the item is not a favorite. + * Added "Play All Track Favorites" action to the track favorites section actions. This will get a list of the tracks saved in the current Spotify user's 'Your Library' and starts playing them, with shuffle enabled.. + * Added logic to player `PREVIOUS_TRACK` control so that if more than 8 seconds have passed the currently playing track is just restarted from the beginning; otherwise, the previous track is selected if progress is past the 8 second point. + ###### [ 1.0.18 ] - 2024/12/11 * This release requires the SpotifyPlus Integration v1.0.69+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. diff --git a/src/card.ts b/src/card.ts index 525b776..d445139 100644 --- a/src/card.ts +++ b/src/card.ts @@ -920,7 +920,7 @@ export class Card extends LitElement { playerHeaderTitle: "{player.source}", playerHeaderArtistTrack: "{player.media_artist} - {player.media_title}", - playerHeaderAlbum: "{player.media_album_name}", + playerHeaderAlbum: "{player.media_album_name} {player.sp_playlist_name_title}", playerHeaderNoMediaPlayingText: "\"{player.name}\" state is \"{player.state}\"", albumFavBrowserTitle: "Album Favorites for {player.sp_user_display_name} ({medialist.itemcount} items)", diff --git a/src/components/media-browser-base.ts b/src/components/media-browser-base.ts index e0ada25..349c59f 100644 --- a/src/components/media-browser-base.ts +++ b/src/components/media-browser-base.ts @@ -105,12 +105,6 @@ export class MediaBrowserBase extends LitElement { // album-specific search types: } else if (this.searchMediaType == SearchMediaTypes.ALBUM_TRACKS) { this.mediaItemType = Section.TRACK_FAVORITES; - //this.mediaItemType = Section.TRACK_FAVORITES; // TODO REMOVEME - //this.itemsPerRow = 1; - //this.hideTitle = false; - //this.hideSubTitle = false; - //this.listItemClass += ' button-track'; - //return; // artist-specific search types: } else if (this.searchMediaType == SearchMediaTypes.ARTIST_ALBUMS) { this.mediaItemType = Section.ALBUM_FAVORITES; @@ -610,12 +604,6 @@ export class MediaBrowserBase extends LitElement { mbi_info.subtitle = (itemInfo.total_episodes || 0) + " episodes"; } else if (this.mediaItemType == Section.TRACK_FAVORITES) { const itemInfo = (item as ITrackSimplified); - //if (this.searchMediaType == SearchMediaTypes.ALBUM_TRACKS) { // TODO REMOVEME - // mbi_info.subtitle = "Track " + itemInfo.track_number; - // if (itemInfo.disc_number > 1) { - // mbi_info.subtitle += ", Disc " + itemInfo.disc_number; - // } - //} if ((itemInfo.artists) && (itemInfo.artists.length > 0)) { mbi_info.subtitle = itemInfo.artists[0].name || item.type; } diff --git a/src/components/player-body-audiobook.ts b/src/components/player-body-audiobook.ts index 385b4df..c911460 100644 --- a/src/components/player-body-audiobook.ts +++ b/src/components/player-body-audiobook.ts @@ -51,13 +51,17 @@ enum Actions { } -class PlayerBodyAudiobook extends PlayerBodyBase { +export class PlayerBodyAudiobook extends PlayerBodyBase { // private state properties. - @state() private isAudiobookFavorite?: boolean; + @state() public isAudiobookFavorite?: boolean; @state() private isChapterFavorite?: boolean; @state() private chapter?: IChapter; + // private properties. + public actionAudiobookFavoriteAdd?: any; + public actionAudiobookFavoriteRemove?: any; + /** * Invoked on each update to perform rendering tasks. @@ -70,7 +74,7 @@ class PlayerBodyAudiobook extends PlayerBodyBase { super.render(); // define actions - audiobook. - const actionAudiobookFavoriteAdd = html` + this.actionAudiobookFavoriteAdd = html`
`; - const actionAudiobookFavoriteRemove = html` + this.actionAudiobookFavoriteRemove = html`
${iconAudiobook} ${this.chapter?.audiobook.name} - ${(this.isAudiobookFavorite ? actionAudiobookFavoriteRemove : actionAudiobookFavoriteAdd)} + ${(this.isAudiobookFavorite ? this.actionAudiobookFavoriteRemove : this.actionAudiobookFavoriteAdd)} ${actionsAudiobookHtml} diff --git a/src/components/player-body-show.ts b/src/components/player-body-show.ts index dcfb7ba..24215bc 100644 --- a/src/components/player-body-show.ts +++ b/src/components/player-body-show.ts @@ -48,13 +48,17 @@ enum Actions { } -class PlayerBodyShow extends PlayerBodyBase { +export class PlayerBodyShow extends PlayerBodyBase { // private state properties. @state() private isShowFavorite?: boolean; - @state() private isEpisodeFavorite?: boolean; + @state() public isEpisodeFavorite?: boolean; @state() private episode?: IEpisode; + // public properties. + public actionEpisodeFavoriteAdd?: any; + public actionEpisodeFavoriteRemove?: any; + /** * Invoked on each update to perform rendering tasks. @@ -89,7 +93,7 @@ class PlayerBodyShow extends PlayerBodyBase {
`; - const actionEpisodeFavoriteAdd = html` + this.actionEpisodeFavoriteAdd = html`
`; - const actionEpisodeFavoriteRemove = html` + this.actionEpisodeFavoriteRemove = html`
${iconEpisode} ${this.episode?.name} - ${(this.isEpisodeFavorite ? actionEpisodeFavoriteRemove : actionEpisodeFavoriteAdd)} + ${(this.isEpisodeFavorite ? this.actionEpisodeFavoriteRemove : this.actionEpisodeFavoriteAdd)} ${actionsEpisodeHtml} diff --git a/src/components/player-body-track.ts b/src/components/player-body-track.ts index 1207676..28518bc 100644 --- a/src/components/player-body-track.ts +++ b/src/components/player-body-track.ts @@ -69,14 +69,18 @@ enum Actions { } -class PlayerBodyTrack extends PlayerBodyBase { +export class PlayerBodyTrack extends PlayerBodyBase { // private state properties. @state() private isAlbumFavorite?: boolean; @state() private isArtistFavorite?: boolean; - @state() private isTrackFavorite?: boolean; + @state() public isTrackFavorite?: boolean; @state() private track?: ITrack; + // public properties. + public actionTrackFavoriteAdd?: any; + public actionTrackFavoriteRemove?: any; + /** * Invoked on each update to perform rendering tasks. @@ -133,7 +137,7 @@ class PlayerBodyTrack extends PlayerBodyBase {
`; - const actionTrackFavoriteAdd = html` + this.actionTrackFavoriteAdd = html`
`; - const actionTrackFavoriteRemove = html` + this.actionTrackFavoriteRemove = html`
${iconTrack} ${this.track?.name} - ${(this.isTrackFavorite ? actionTrackFavoriteRemove : actionTrackFavoriteAdd)} + ${(this.isTrackFavorite ? this.actionTrackFavoriteRemove : this.actionTrackFavoriteAdd)} ${actionsTrackHtml} diff --git a/src/components/player-controls.ts b/src/components/player-controls.ts index 432e712..0e78641 100644 --- a/src/components/player-controls.ts +++ b/src/components/player-controls.ts @@ -27,11 +27,11 @@ import { ProgressEndedEvent } from '../events/progress-ended'; import { ProgressStartedEvent } from '../events/progress-started'; import { closestElement, isCardInEditPreview } from '../utils/utils'; import { Player } from '../sections/player'; +import { PlayerBodyQueue } from './player-body-queue'; // debug logging. import Debug from 'debug/src/browser.js'; import { DEBUG_APP_NAME } from '../constants'; -import { PlayerBodyQueue } from './player-body-queue'; const debuglog = Debug(DEBUG_APP_NAME + ":player-controls"); const { NEXT_TRACK, PAUSE, PLAY, PREVIOUS_TRACK, REPEAT_SET, SHUFFLE_SET, TURN_ON, ACTION_FAVES, PLAY_QUEUE } = MediaPlayerEntityFeature; @@ -420,7 +420,25 @@ class PlayerControls extends LitElement { } else if (action == PREVIOUS_TRACK) { - await this.mediaControlService.media_previous_track(this.player); + // the following is the same formula used in Progress class (trackProgress method). + // get current track positioning from media player attributes. + const position = this.player?.attributes.media_position || 0; + const playing = this.player?.isPlaying(); + const updatedAt = this.player?.attributes.media_position_updated_at || 0; + let playingProgress = position; + + // calculate progress. + if (playing) { + playingProgress = position + (Date.now() - new Date(updatedAt).getTime()) / 1000.0; + } + + // if more than 8 seconds have passed then just restart the track; + // otherwise, select the previous track. + if (playingProgress > 8) { + await this.mediaControlService.media_seek(this.player, 0); + } else { + await this.mediaControlService.media_previous_track(this.player); + } } else if (action == REPEAT_SET) { diff --git a/src/components/player-header.ts b/src/components/player-header.ts index c8dc8db..20ac187 100644 --- a/src/components/player-header.ts +++ b/src/components/player-header.ts @@ -5,10 +5,14 @@ import { styleMap } from 'lit-html/directives/style-map.js'; // our imports. import '../components/player-progress'; +import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; import { CardConfig } from '../types/card-config'; import { Store } from '../model/store'; import { MediaPlayer } from '../model/media-player'; import { formatTitleInfo } from '../utils/media-browser-utils'; +import { PlayerBodyAudiobook } from './player-body-audiobook'; +import { PlayerBodyShow } from './player-body-show'; +import { PlayerBodyTrack } from './player-body-track'; class PlayerHeader extends LitElement { @@ -46,6 +50,45 @@ class PlayerHeader extends LitElement { artistTrack = artistTrack.replace(/^ - | - $/g, ''); } + // initialize favorite settings. + let isFavoriteReady = false; + let isFavorite: boolean | undefined = undefined; + let actionFavoriteAdd = html``; + let actionFavoriteRemove = html``; + + // only need to do this if media is playing. + if (this.player.isPlaying()) { + + // find body content element; this could be any of the following: + // SPC-PLAYER-BODY-AUDIOBOOK, SPC-PLAYER-BODY-SHOW, SPC-PLAYER-BODY-TRACK. + const elmBody = this.parentElement?.querySelector(".player-section-body-content") as HTMLElement; + if (elmBody) { + + const tagName = elmBody.tagName.toLowerCase(); + + // retrieve favorite details based on the player body type (audiobook chapter, show episode, track). + if (tagName == ("spc-player-body-track")) { + const elmPlayerBodyTrack = elmBody as PlayerBodyTrack + isFavorite = elmPlayerBodyTrack.isTrackFavorite; + actionFavoriteAdd = elmPlayerBodyTrack.actionTrackFavoriteAdd; + actionFavoriteRemove = elmPlayerBodyTrack.actionTrackFavoriteRemove; + isFavoriteReady = true; + } else if (tagName == ("spc-player-body-show")) { + const elmPlayerBodyShow = elmBody as PlayerBodyShow + isFavorite = elmPlayerBodyShow.isEpisodeFavorite; + actionFavoriteAdd = elmPlayerBodyShow.actionEpisodeFavoriteAdd; + actionFavoriteRemove = elmPlayerBodyShow.actionEpisodeFavoriteRemove; + isFavoriteReady = true; + } else if (tagName == ("spc-player-body-audiobook")) { + const elmPlayerBodyAudiobook = elmBody as PlayerBodyAudiobook + isFavorite = elmPlayerBodyAudiobook.isAudiobookFavorite; + actionFavoriteAdd = elmPlayerBodyAudiobook.actionAudiobookFavoriteAdd; + actionFavoriteRemove = elmPlayerBodyAudiobook.actionAudiobookFavoriteRemove + isFavoriteReady = true; + } + } + } + // if nothing is playing then display configured 'no media playing' text. if (!this.player.attributes.media_title) { artistTrack = formatTitleInfo(this.config.playerHeaderNoMediaPlayingText, this.config, this.player) || 'No Media Playing'; @@ -57,7 +100,11 @@ class PlayerHeader extends LitElement {
${!hideProgress ? html`` : html``}
${title}
- ${artistTrack ? html`
${artistTrack}
` : html``} + ${artistTrack ? html` +
${artistTrack} + ${(isFavoriteReady ? html`${(isFavorite ? actionFavoriteRemove : actionFavoriteAdd)}` : html``)} +
+ ` : html``} ${album ? html`
${album}
` : html``}
`; } @@ -75,7 +122,9 @@ class PlayerHeader extends LitElement { * style definitions used by this component. * */ static get styles() { - return css` + return [ + sharedStylesFavActions, + css` .player-header-container { margin: 0.75rem 3.25rem; @@ -127,7 +176,8 @@ static get styles() { color: var(--spc-player-header-color); mix-blend-mode: screen; } - `; + ` + ]; } } diff --git a/src/components/track-actions.ts b/src/components/track-actions.ts index 6705bbf..e3ef414 100644 --- a/src/components/track-actions.ts +++ b/src/components/track-actions.ts @@ -65,6 +65,7 @@ enum Actions { TrackFavoriteRemove = "TrackFavoriteRemove", TrackFavoriteUpdate = "TrackFavoriteUpdate", TrackPlayQueueAdd = "TrackPlayQueueAdd", + TrackPlayTrackFavorites = "TrackPlayTrackFavorites", TrackSearchPlaylists = "TrackSearchPlaylists", TrackSearchRadio = "TrackSearchRadio", } @@ -222,6 +223,10 @@ class TrackActions extends FavActionsBase {
Add Track to Play Queue
+ this.onClickAction(Actions.TrackPlayTrackFavorites)}> + +
Play All Track Favorites
+
this.onClickAction(Actions.TrackCopyUriToClipboard)}> @@ -605,6 +610,15 @@ class TrackActions extends FavActionsBase { await this.spotifyPlusService.AddPlayerQueueItems(this.player.id, this.mediaItem.uri, null, false); this.progressHide(); + } else if (action == Actions.TrackPlayTrackFavorites) { + + // have to hide the progress indicator manually since it does not call updateActions. + await this.spotifyPlusService.PlayerMediaPlayTrackFavorites(this.player.id, null, true, null, false, this.store.config.trackFavBrowserItemsLimit); + this.progressHide(); + + // show player section. + this.store.card.SetSection(Section.PLAYER); + } else { // no action selected - hide progress indicator. diff --git a/src/constants.ts b/src/constants.ts index e5bbf17..0fb222b 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.17'; +export const CARD_VERSION = '1.0.19'; /** SpotifyPlus integration domain identifier. */ export const DOMAIN_SPOTIFYPLUS = 'spotifyplus'; diff --git a/src/services/spotifyplus-service.ts b/src/services/spotifyplus-service.ts index 5cb4431..466f9f7 100644 --- a/src/services/spotifyplus-service.ts +++ b/src/services/spotifyplus-service.ts @@ -2923,6 +2923,75 @@ export class SpotifyPlusService { } + /** + * Get a list of the tracks saved in the current Spotify user's 'Your Library' + * and starts playing them. + * + * @param entity_id + * Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param device_id + * The name or id of the device this command is targeting. + * If not supplied, the user's currently active device is the target. + * Example: `Office`, `0d1841b0976bae2a3a310dd74c0f3df354899bc8` + * @param shuffle + * True to set player shuffle mode to on; otherwise, False for no shuffle. + * @param delay + * Time delay (in seconds) to wait AFTER issuing the command to the player. + * This delay will give the spotify web api time to process the change before + * another command is issued. + * Default is 0.50; value range is 0 - 10. + * @param resolve_device_id + * True to resolve the supplied `deviceId` value; otherwise, False not resolve the `deviceId` + * value as it has already been resolved. + * Default is True. + * @param limit_total + * The maximum number of items to retrieve from favorites. + * Default: 200. + */ + public async PlayerMediaPlayTrackFavorites( + entity_id: string, + device_id: string | undefined | null = null, + shuffle: boolean | undefined | null = null, + delay: number | null = null, + resolve_device_id: boolean | undefined | null = null, + limit_total: number | null = null, + ): 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 (device_id) + serviceData['device_id'] = device_id; + if (shuffle != null) + serviceData['shuffle'] = shuffle; + if (delay) + serviceData['delay'] = delay; + if (resolve_device_id) + serviceData['resolve_device_id'] = resolve_device_id; + if (limit_total) + serviceData['limit_total'] = limit_total; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'player_media_play_track_favorites', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + /** * Start playing one or more tracks of the specified context on a Spotify Connect device. * diff --git a/src/types/hass-entity-attributes-media-player.ts b/src/types/hass-entity-attributes-media-player.ts index 5d5ce43..2553a57 100644 --- a/src/types/hass-entity-attributes-media-player.ts +++ b/src/types/hass-entity-attributes-media-player.ts @@ -7,37 +7,171 @@ import { RepeatMode } from '../services/media-control-service'; * Hass state attributes provided by the HA MediaPlayer integration. */ export declare type HassEntityAttributesMediaPlayer = HassEntityAttributeBase & { + + /** + * + */ app_id?: string; + + /** + * + */ app_name?: string; + + /** + * + */ device_class?: string; + + /** + * + */ entity_picture_local?: string; + + /** + * An array of members in the group. + */ group_members?: [string]; + + /** + * True if volume is currently muted; otherwise, false. + */ is_volume_muted?: boolean; + + /** + * Media Album Artist name. + */ media_album_artist?: string; + + /** + * Media Album name. + */ media_album_name?: string; + + /** + * Media Artist name. + */ media_artist?: string; + + /** + * Media channel name. + */ media_channel?: string; + + /** + * URL of media currently playing. + */ media_content_id?: string; + + /** + * Type of media currently playing. + */ media_content_type?: string; + + /** + * Duration of current playing media in seconds. + */ media_duration?: number; + + /** + * Media episode number. + */ media_episode?: string; + + /** + * + */ media_image_hash?: string; + + /** + * True if media image is remotely accessible; otherwise, false. + */ media_image_remotely_accessible?: boolean; + + /** + * Media image URL. + */ media_image_url?: string; + + /** + * Title of current playing playlist; otherwise, null if no playlist. + */ media_playlist?: string; - media_position_updated_at?: string; // dt.datetime | None = None + + /** + * When was the position of the current playing media was last refreshed + * (not calculated) from the source. + */ + media_position_updated_at?: string; + + /** + * Position of current playing media in seconds. + */ media_position?: number; + + /** + * Media season title. + */ media_season?: string; + + /** + * Media series title. + */ media_series_title?: string; + + /** + * Media title. + */ media_title?: string; + + /** + * Track number of current playing media, music track only. + */ media_track?: number; + + /** + * Current repeat mode. + */ repeat?: RepeatMode; + + /** + * Current shuffle state. + */ shuffle?: boolean; + + /** + * List of sound modes if supported; otherwise, null. + */ sound_mode_list?: [string]; + + /** + * Currently selected sound mode.. + */ sound_mode?: string; + + /** + * List of source devices if supported; otherwise, null. + */ source_list?: [string]; + + /** + * Currently selected source. + */ source?: string; + + /** + * Current playback state. + */ state?: string; // MediaPlayerState | None = None + + /** + * Supported feature flags. + */ supported_features?: number; // MediaPlayerEntityFeature(0) + + /** + * Volume level of the media player (0.0 to 1.0). + */ volume_level?: number; + }; diff --git a/src/utils/media-browser-utils.ts b/src/utils/media-browser-utils.ts index 33e1378..58720df 100644 --- a/src/utils/media-browser-utils.ts +++ b/src/utils/media-browser-utils.ts @@ -225,7 +225,11 @@ export function formatPlayerInfo( text = text.replace("{player.sp_device_name}", player.attributes.sp_device_name || ''); text = text.replace("{player.sp_item_type}", player.attributes.sp_item_type || ''); text = text.replace("{player.sp_playlist_name}", player.attributes.sp_playlist_name || ''); - text = text.replace("{player.sp_playlist_name_title}", "Playlist: " + (player.attributes.sp_playlist_name || 'n/a')); + if ((player.attributes.sp_playlist_name) && (player.attributes.sp_playlist_name != "Unknown")) { + text = text.replace("{player.sp_playlist_name_title}", " (" + player.attributes.sp_playlist_name + ")"); + } else { + text = text.replace("{player.sp_playlist_name_title}", ""); + } text = text.replace("{player.sp_playlist_uri}", player.attributes.sp_playlist_uri || ''); text = text.replace("{player.sp_user_country}", player.attributes.sp_user_country || ''); text = text.replace("{player.sp_user_display_name}", player.attributes.sp_user_display_name || ''); From 2b9313ec65dd4146477ca018064c7e766e5e7819 Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Wed, 18 Dec 2024 13:31:08 -0600 Subject: [PATCH 10/17] [ 1.0.21 ] * This release requires the SpotifyPlus Integration v1.0.72+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. * Added new user-defined preset type `trackfavorites`, which allows you to play all track favorites by simply selecting the preset. There is also the "Play All Track Favorites" action in the track favorites section actions, but the preset makes it easier to play all tracks. --- CHANGELOG.md | 14 ++++- src/components/player-body-queue.ts | 6 ++ src/components/player-controls.ts | 8 ++- src/components/player-volume.ts | 22 ++++++- src/components/track-actions.ts | 6 +- src/constants.ts | 7 ++- src/main.ts | 8 ++- src/model/media-player.ts | 11 +++- src/model/store.ts | 27 +++++++-- src/sections/userpreset-browser.ts | 60 ++++++++++++++++++- src/services/media-control-service.ts | 10 ++-- src/services/spotifyplus-service.ts | 15 +++-- .../spotifyplus-hass-entity-attributes.ts | 15 ++++- src/types/spotifyplus/user-preset.ts | 8 ++- src/utils/media-browser-utils.ts | 14 +---- 15 files changed, 185 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a21061..cfcb089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,22 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.21 ] - 2024/12/18 + + * This release requires the SpotifyPlus Integration v1.0.72+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. + * Added new user-defined preset type `trackfavorites`, which allows you to play all track favorites by simply selecting the preset. There is also the "Play All Track Favorites" action in the track favorites section actions, but the preset makes it easier to play all tracks. + +###### [ 1.0.20 ] - 2024/12/17 + + * This release requires the SpotifyPlus Integration v1.0.71+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. + * Corrected a bug that was causing the wrong media player to be displayed when multiple SpotifyPlus media players were defined with the same prefix. + * Hide Media controls for Spotify Free accounts, as a Spotify Premium membership is required for those functions. + * Hide Volume controls for Spotify Free accounts, as a Spotify Premium membership is required for those functions. + ###### [ 1.0.19 ] - 2024/12/15 * Added favorite indicator to the player section form for track, show episode, and audiobook items. The heart icon will be displayed to the right of the item name. A solid red heart indicates the item is a favorite; a transparent heart indicates the item is not a favorite. - * Added "Play All Track Favorites" action to the track favorites section actions. This will get a list of the tracks saved in the current Spotify user's 'Your Library' and starts playing them, with shuffle enabled.. + * Added "Play All Track Favorites" action to the track favorites section actions. This will get a list of the tracks saved in the current Spotify user's 'Your Library' and starts playing them, with shuffle enabled. * Added logic to player `PREVIOUS_TRACK` control so that if more than 8 seconds have passed the currently playing track is just restarted from the beginning; otherwise, the previous track is selected if progress is past the 8 second point. ###### [ 1.0.18 ] - 2024/12/11 diff --git a/src/components/player-body-queue.ts b/src/components/player-body-queue.ts index 9650c55..8ef1355 100644 --- a/src/components/player-body-queue.ts +++ b/src/components/player-body-queue.ts @@ -262,6 +262,12 @@ export class PlayerBodyQueue extends PlayerBodyBase { // we want to manually force the refresh when the queue body is is displayed. if (updateActions.indexOf(Actions.GetPlayerQueueInfo) != -1) { + // if not premium account then don't allow it as it will fail anyway. + if (!this.player.isUserProductPremium()) { + this.alertErrorSet("Spotify Premium is required to display the player queue."); + return true; + } + // create promise - update currently playing media item. const promiseGetPlayingItem = new Promise((resolve, reject) => { diff --git a/src/components/player-controls.ts b/src/components/player-controls.ts index 0e78641..b140010 100644 --- a/src/components/player-controls.ts +++ b/src/components/player-controls.ts @@ -34,7 +34,9 @@ import Debug from 'debug/src/browser.js'; import { DEBUG_APP_NAME } from '../constants'; const debuglog = Debug(DEBUG_APP_NAME + ":player-controls"); -const { NEXT_TRACK, PAUSE, PLAY, PREVIOUS_TRACK, REPEAT_SET, SHUFFLE_SET, TURN_ON, ACTION_FAVES, PLAY_QUEUE } = MediaPlayerEntityFeature; +const { NEXT_TRACK, PAUSE, PLAY, PREVIOUS_TRACK, REPEAT_SET, SHUFFLE_SET, TURN_ON } = MediaPlayerEntityFeature; +const ACTION_FAVES = 900000000000; +const PLAY_QUEUE = 990000000000; class PlayerControls extends LitElement { @@ -328,7 +330,7 @@ class PlayerControls extends LitElement { * * @param action Action to execute. */ - private async onClickAction(action: MediaPlayerEntityFeature): Promise { + private async onClickAction(action: any): Promise { try { @@ -493,7 +495,7 @@ class PlayerControls extends LitElement { * * @param feature Feature identifier to check. */ - private hideFeature(feature: MediaPlayerEntityFeature) { + private hideFeature(feature: any) { if (feature == PAUSE) { diff --git a/src/components/player-volume.ts b/src/components/player-volume.ts index b50a65c..9835e84 100644 --- a/src/components/player-volume.ts +++ b/src/components/player-volume.ts @@ -18,7 +18,7 @@ import { ProgressStartedEvent } from '../events/progress-started'; import { closestElement } from '../utils/utils'; import { Player } from '../sections/player'; -const { TURN_OFF, TURN_ON } = MediaPlayerEntityFeature; +const { TURN_OFF, TURN_ON, VOLUME_MUTE, VOLUME_SET } = MediaPlayerEntityFeature; class Volume extends LitElement { @@ -62,8 +62,15 @@ class Volume extends LitElement { // render control. return html`
- ${!hideMute ? html`` : html``} -
+ ${!hideMute ? html` + + ` : html``} +
ent.entity_id.match(entityId)); // if not, then it's an error! - if (!hassEntity) - throw new Error("Entity id '" + JSON.stringify(entityId) + "' does not exist in the state machine"); + if (!hassEntitys) { + throw new Error("Entity id '" + JSON.stringify(entityId) + "' could not be matched in the state machine"); + } - // convert the hass state representation to a media player object. - return new MediaPlayer(hassEntity[0]); + // find the exact matching HA media player entity and create the media player instance. + let player: MediaPlayer | null = null; + hassEntitys.forEach(item => { + const haEntity = item as HassEntity; + if (haEntity.entity_id.toLowerCase() == entityId.toLowerCase()) { + player = new MediaPlayer(haEntity); + } + }) + + // did we find the player? + if (player) { + return player; + } else { + throw new Error("Entity id '" + JSON.stringify(entityId) + "' does not exist in the state machine"); + } } } diff --git a/src/sections/userpreset-browser.ts b/src/sections/userpreset-browser.ts index c8f5f6b..af47278 100644 --- a/src/sections/userpreset-browser.ts +++ b/src/sections/userpreset-browser.ts @@ -13,6 +13,7 @@ import { formatTitleInfo } from '../utils/media-browser-utils'; import { getUtcNowTimestamp } from '../utils/utils'; import { IUserPreset } from '../types/spotifyplus/user-preset'; import { CategoryDisplayEvent } from '../events/category-display'; +import { ALERT_ERROR_SPOTIFY_PREMIUM_REQUIRED } from '../constants'; @customElement("spc-userpreset-browser") @@ -114,6 +115,11 @@ export class UserPresetBrowser extends FavBrowserBase { const preset = evArgs.detail as IUserPreset; this.dispatchEvent(CategoryDisplayEvent(preset.subtitle, preset.name, preset.uri)); + } else if (evArgs.detail.type == "trackfavorites") { + + const mediaItem = evArgs.detail as IUserPreset; + this.PlayTrackFavorites(mediaItem); + } else { // call base class method to handle it. @@ -131,8 +137,8 @@ export class UserPresetBrowser extends FavBrowserBase { */ protected override onItemSelectedWithHold(args: CustomEvent) { - // is this a recommendations type? - if (args.detail.type == "recommendations") { + // does media item have a uri value (e.g. "recommendations","trackfavorites", etc)? + if ((args.detail.uri || "") == "") { // set the uri value to fool the base class validations. // note that uri property is not used by recommendations. args.detail.uri = "unknown"; @@ -154,6 +160,10 @@ export class UserPresetBrowser extends FavBrowserBase { try { + if (!this.player.isUserProductPremium()) { + throw new Error(ALERT_ERROR_SPOTIFY_PREMIUM_REQUIRED); + } + // show progress indicator. this.progressShow(); @@ -205,6 +215,52 @@ export class UserPresetBrowser extends FavBrowserBase { } + /** + * Calls the SpotifyPlusService PlayerMediaPlayTrackFavorites method to play all + * track favorites. + * + * @param preset The user preset item that was selected. + */ + protected async PlayTrackFavorites(preset: IUserPreset): Promise { + + try { + + if (!this.player.isUserProductPremium()) { + throw new Error(ALERT_ERROR_SPOTIFY_PREMIUM_REQUIRED); + } + + // show progress indicator. + this.progressShow(); + + // update status. + this.alertInfo = "Playing track favorites ..."; + this.requestUpdate(); + + // play favorite tracks. + const device_id = this.player.attributes.source || null; + const shuffle = preset.shuffle || ((this.player.attributes.shuffle != null) ? this.player.attributes.shuffle : true); + await this.spotifyPlusService.PlayerMediaPlayTrackFavorites(this.player.id, device_id, shuffle, null, true, this.config.trackFavBrowserItemsLimit || 200); + + // show player section. + this.store.card.SetSection(Section.PLAYER); + + } + catch (error) { + + // set error message and reset scroll position to zero so the message is displayed. + this.alertErrorSet("Could not play track favorites for user preset. " + (error as Error).message); + this.mediaBrowserContentElement.scrollTop = 0; + + } + finally { + + // hide progress indicator. + this.progressHide(); + + } + } + + /** * Updates the mediaList display. */ diff --git a/src/services/media-control-service.ts b/src/services/media-control-service.ts index 4186b0d..9fc9f10 100644 --- a/src/services/media-control-service.ts +++ b/src/services/media-control-service.ts @@ -5,7 +5,7 @@ import { ServiceCallRequest } from '../types/home-assistant-frontend/service-cal // our imports. import { MediaPlayerItem } from '../types'; import { MediaPlayer } from '../model/media-player'; -import { DOMAIN_MEDIA_PLAYER } from '../constants'; +import { ALERT_ERROR_SPOTIFY_PREMIUM_REQUIRED, DOMAIN_MEDIA_PLAYER } from '../constants'; // media player services. const SERVICE_TURN_ON = "turn_on"; @@ -317,6 +317,11 @@ export class MediaControlService { */ public async select_source(player: MediaPlayer, source: string) { + // spotify premium required for this function. + if (!player.isUserProductPremium()) { + throw new Error(ALERT_ERROR_SPOTIFY_PREMIUM_REQUIRED); + } + // create service request. const serviceRequest: ServiceCallRequest = { domain: DOMAIN_MEDIA_PLAYER, @@ -518,9 +523,6 @@ export enum MediaPlayerEntityFeature { REPEAT_SET = 262144, GROUPING = 524288, - // added the following for SpotifyPlus custom functions. - ACTION_FAVES = 900000000000, - PLAY_QUEUE = 990000000000, } diff --git a/src/services/spotifyplus-service.ts b/src/services/spotifyplus-service.ts index 466f9f7..59863c1 100644 --- a/src/services/spotifyplus-service.ts +++ b/src/services/spotifyplus-service.ts @@ -9,7 +9,7 @@ import { } from '@mdi/js'; // our imports. -import { DOMAIN_SPOTIFYPLUS } from '../constants'; +import { ALERT_ERROR_SPOTIFY_PREMIUM_REQUIRED, DOMAIN_SPOTIFYPLUS } from '../constants'; import { MediaPlayer } from '../model/media-player'; import { getMdiIconImageUrl } from '../utils/media-browser-utils'; import { SearchMediaTypes } from '../types/search-media-types'; @@ -193,7 +193,7 @@ export class SpotifyPlusService { // update service data parameters (with optional parameters). if (device_id) serviceData['device_id'] = device_id; - if (verify_device_id) + if (verify_device_id != null) serviceData['verify_device_id'] = verify_device_id; if (delay) serviceData['delay'] = delay; @@ -563,7 +563,7 @@ export class SpotifyPlusService { // update service data parameters (with optional parameters). if (playlist_id) serviceData['playlist_id'] = playlist_id; - if (is_public) + if (is_public != null) serviceData['public'] = is_public; // create service request. @@ -2971,7 +2971,7 @@ export class SpotifyPlusService { serviceData['shuffle'] = shuffle; if (delay) serviceData['delay'] = delay; - if (resolve_device_id) + if (resolve_device_id != null) serviceData['resolve_device_id'] = resolve_device_id; if (limit_total) serviceData['limit_total'] = limit_total; @@ -4219,9 +4219,9 @@ export class SpotifyPlusService { serviceData['password'] = password; if (loginid) serviceData['loginid'] = loginid; - if (pre_disconnect) + if (pre_disconnect != null) serviceData['pre_disconnect'] = pre_disconnect; - if (verify_device_list_entry) + if (verify_device_list_entry != null) serviceData['verify_device_list_entry'] = verify_device_list_entry; if (delay) serviceData['delay'] = delay; @@ -4320,6 +4320,9 @@ export class SpotifyPlusService { if (!mediaItem) { throw new Error("Media browser item argument was not supplied to the PlayMediaBrowserItem service."); } + if (!player.isUserProductPremium()) { + throw new Error(ALERT_ERROR_SPOTIFY_PREMIUM_REQUIRED); + } try { diff --git a/src/types/spotifyplus-hass-entity-attributes.ts b/src/types/spotifyplus-hass-entity-attributes.ts index 67fbb71..58dba0f 100644 --- a/src/types/spotifyplus-hass-entity-attributes.ts +++ b/src/types/spotifyplus-hass-entity-attributes.ts @@ -27,7 +27,7 @@ export declare type SpotifyPlusHassEntityAttributes = HassEntityAttributesMediaP /** * Denotes if the source device is a Sonos brand device (true) or not (false). */ - sp_device_is_brand_sonos?: string; + sp_device_is_brand_sonos?: boolean; /** * Denotes the type of item being played: `track`, `podcast`, or `audiobook`. @@ -35,7 +35,13 @@ export declare type SpotifyPlusHassEntityAttributes = HassEntityAttributesMediaP sp_item_type?: string; /** - * Playlist name being played, if the current context is a playlist (e.g. "Daily Mix 1"). + * The object type of the currently playing item, or null if nothing is playing. + * If not null, it can be one of `track`, `episode`, `ad` or `unknown`. + */ + sp_playing_type?: string; + + /** + * Playlist name being played, if the current context is a playlist (e.g. "DJ"). */ sp_playlist_name?: string; @@ -44,6 +50,11 @@ export declare type SpotifyPlusHassEntityAttributes = HassEntityAttributesMediaP */ sp_playlist_uri?: string; + /** + * True if the track / episode has explicit content; otherwise, false. + */ + sp_track_is_explicit?: boolean; + /** * Country code for the active Spotify user account (e.g. "US"). */ diff --git a/src/types/spotifyplus/user-preset.ts b/src/types/spotifyplus/user-preset.ts index ca00c71..210dd97 100644 --- a/src/types/spotifyplus/user-preset.ts +++ b/src/types/spotifyplus/user-preset.ts @@ -48,11 +48,17 @@ export interface IUserPreset { /** - * Properties used for calls to the GetTrackRecommendations service. or null. + * Properties used for calls to the GetTrackRecommendations service, or null. * This property should only be populated for type = "recommendations". */ recommendations?: ITrackRecommendationsProperties | null; + /** + * True if shuffle is enabled; otherwise, false (or null). + * This property should only be populated for type = "trackfavorites". + */ + shuffle?: boolean | null; + } diff --git a/src/utils/media-browser-utils.ts b/src/utils/media-browser-utils.ts index 58720df..4984d2a 100644 --- a/src/utils/media-browser-utils.ts +++ b/src/utils/media-browser-utils.ts @@ -223,7 +223,9 @@ export function formatPlayerInfo( text = text.replace("{player.sp_context_uri}", player.attributes.sp_context_uri || ''); text = text.replace("{player.sp_device_id}", player.attributes.sp_device_id || ''); text = text.replace("{player.sp_device_name}", player.attributes.sp_device_name || ''); + text = text.replace("{player.sp_device_is_brand_sonos}", (player.attributes.sp_device_is_brand_sonos + "") || ''); text = text.replace("{player.sp_item_type}", player.attributes.sp_item_type || ''); + text = text.replace("{player.sp_playing_type}", player.attributes.sp_playing_type || ''); text = text.replace("{player.sp_playlist_name}", player.attributes.sp_playlist_name || ''); if ((player.attributes.sp_playlist_name) && (player.attributes.sp_playlist_name != "Unknown")) { text = text.replace("{player.sp_playlist_name_title}", " (" + player.attributes.sp_playlist_name + ")"); @@ -231,6 +233,7 @@ export function formatPlayerInfo( text = text.replace("{player.sp_playlist_name_title}", ""); } text = text.replace("{player.sp_playlist_uri}", player.attributes.sp_playlist_uri || ''); + text = text.replace("{player.sp_track_is_explicit}", (player.attributes.sp_track_is_explicit + "") || ''); text = text.replace("{player.sp_user_country}", player.attributes.sp_user_country || ''); text = text.replace("{player.sp_user_display_name}", player.attributes.sp_user_display_name || ''); text = text.replace("{player.sp_user_email}", player.attributes.sp_user_email || ''); @@ -238,17 +241,6 @@ export function formatPlayerInfo( text = text.replace("{player.sp_user_product}", player.attributes.sp_user_product || ''); text = text.replace("{player.sp_user_uri}", player.attributes.sp_user_uri || ''); - // other possible keywords: - //media_duration: 276 - //media_position: 182 - //media_position_updated_at: "2024-04-30T21:32:12.303343+00:00" - //shuffle: false - //repeat: "off" - //device_class: speaker - //entity_picture: /api/media_player_proxy/media_player.bose_st10_1?token=f447f9b3fbdb647d9df2f7b0a5a474be9e17ffa51d26eb18f414d5120a2bdeb8&cache=2a8a6a76b27e209a - //icon: mdi: speaker - //supported_features: 1040319 - } return text; From b3191595abd5db22c32c90fa59ef1f5eb5f7f3ec Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Wed, 18 Dec 2024 15:09:59 -0600 Subject: [PATCH 11/17] [ 1.0.22 ] * This release requires the SpotifyPlus Integration v1.0.72+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. * Fixed card not rendering correctly in the card picker and when an entity id was not selected. --- CHANGELOG.md | 5 +++++ src/card.ts | 9 ++++++++- src/constants.ts | 2 +- src/model/store.ts | 21 +++++++++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfcb089..a316d0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.22 ] - 2024/12/18 + + * This release requires the SpotifyPlus Integration v1.0.72+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. + * Fixed card not rendering correctly in the card picker and when an entity id was not selected. + ###### [ 1.0.21 ] - 2024/12/18 * This release requires the SpotifyPlus Integration v1.0.72+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. diff --git a/src/card.ts b/src/card.ts index d445139..f274135 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1010,11 +1010,18 @@ export class Card extends LitElement { userPresets: [ { - "name": "Spotify Playlist Daily Mix 1", + "name": "Daily Mix 1", "subtitle": "Various Artists", "image_url": "https://dailymix-images.scdn.co/v2/img/ab6761610000e5ebcd3f796bd7ea49ed7615a550/1/en/default", "uri": "spotify:playlist:37i9dQZF1E39vTG3GurFPW", "type": "playlist" + }, + { + "name": "My Track Favorites", + "subtitle": "Shuffled", + "image_url": "https://t.scdn.co/images/728ed47fc1674feb95f7ac20236eb6d7.jpeg", + "shuffle": true, + "type": "trackfavorites" } ], diff --git a/src/constants.ts b/src/constants.ts index 09855e2..01f1d59 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.21'; +export const CARD_VERSION = '1.0.22'; /** SpotifyPlus integration domain identifier. */ export const DOMAIN_SPOTIFYPLUS = 'spotifyplus'; diff --git a/src/model/store.ts b/src/model/store.ts index 7e5427d..93da0b0 100644 --- a/src/model/store.ts +++ b/src/model/store.ts @@ -78,6 +78,7 @@ export class Store { this.spotifyPlusService = new SpotifyPlusService(hass, card); this.player = this.getMediaPlayerObject(playerId); this.section = section; + } @@ -90,6 +91,26 @@ export class Store { */ public getMediaPlayerObject(entityId: string) { + // has an entity been configured? + if ((!this.config) || (!this.config.entity) || (this.config.entity.trim() == "")) { + + // entityId will not be set in the config if coming from the card picker; + // this is ok, as we want it to render a "needs configured" card ui. + // in this case, we just create an "empty" MediaPlayer instance. + return new MediaPlayer({ + entity_id: "", + state: "", + last_changed: "", + last_updated: "", + attributes: {}, + context: { + id: "", + user_id: "", + parent_id: "", + } + }); + } + // does entity id prefix exist in hass state data? const hassEntitys = Object.values(this.hass.states) .filter((ent) => ent.entity_id.match(entityId)); From d8adb3716a1cb982183f596b5d09d90dc4454519 Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Fri, 20 Dec 2024 08:38:09 -0600 Subject: [PATCH 12/17] [ 1.0.23 ] * This release requires the SpotifyPlus Integration v1.0.73+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. * Fixed hidden volume controls, which was caused by a bug introduced with v1.0.20. * Added the ability to disable image caching for userpreset images. More info can be found on the [wiki docs](https://github.com/thlucas1/spotifyplus_card/wiki/Configuration-Options#userpresets-image-url-caching.) --- CHANGELOG.md | 6 ++++++ src/components/player-controls.ts | 3 +-- src/components/player-volume.ts | 10 ++++++---- src/constants.ts | 2 +- src/sections/userpreset-browser.ts | 10 ++++++++-- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a316d0e..f10b054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.23 ] - 2024/12/20 + + * This release requires the SpotifyPlus Integration v1.0.73+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. + * Fixed hidden volume controls, which was caused by a bug introduced with v1.0.20. + * Added the ability to disable image caching for userpreset images. More info can be found on the [wiki docs](https://github.com/thlucas1/spotifyplus_card/wiki/Configuration-Options#userpresets-image-url-caching.) + ###### [ 1.0.22 ] - 2024/12/18 * This release requires the SpotifyPlus Integration v1.0.72+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. diff --git a/src/components/player-controls.ts b/src/components/player-controls.ts index b140010..1defbad 100644 --- a/src/components/player-controls.ts +++ b/src/components/player-controls.ts @@ -548,9 +548,8 @@ class PlayerControls extends LitElement { } else if (feature == TURN_ON) { if (this.player.supportsFeature(TURN_ON)) { - //if ([MediaPlayerState.OFF, MediaPlayerState.UNKNOWN, MediaPlayerState.STANDBY].includes(this.player.state)) { if ([MediaPlayerState.OFF, MediaPlayerState.STANDBY].includes(this.player.state)) { - return nothing; // show icon + return (this.config.playerVolumeControlsHidePower) ? true : nothing; } return true; // hide icon } diff --git a/src/components/player-volume.ts b/src/components/player-volume.ts index 9835e84..136d92c 100644 --- a/src/components/player-volume.ts +++ b/src/components/player-volume.ts @@ -227,7 +227,7 @@ class Volume extends LitElement { if (this.player.supportsFeature(TURN_ON)) { if ([MediaPlayerState.OFF, MediaPlayerState.UNKNOWN, MediaPlayerState.STANDBY].includes(this.player.state)) { - return (this.config.playerVolumeControlsHidePower || nothing); // show / hide icon based on config settings + return (this.config.playerVolumeControlsHidePower) ? true : nothing; } return true; // hide icon } @@ -236,18 +236,20 @@ class Volume extends LitElement { if (this.player.supportsFeature(TURN_OFF)) { if (![MediaPlayerState.OFF, MediaPlayerState.UNKNOWN, MediaPlayerState.STANDBY].includes(this.player.state)) { - return (this.config.playerVolumeControlsHidePower || nothing); // show / hide icon based on config settings + return (this.config.playerVolumeControlsHidePower) ? true : nothing; } return true; // hide icon } } else if (feature == VOLUME_MUTE) { - return !this.player.supportsFeature(VOLUME_MUTE); + if (this.player.supportsFeature(VOLUME_MUTE)) + return nothing; } else if (feature == VOLUME_SET) { - return !this.player.supportsFeature(VOLUME_SET); + if (this.player.supportsFeature(VOLUME_SET)) + return nothing; } diff --git a/src/constants.ts b/src/constants.ts index 01f1d59..e97fdd0 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.22'; +export const CARD_VERSION = '1.0.23'; /** SpotifyPlus integration domain identifier. */ export const DOMAIN_SPOTIFYPLUS = 'spotifyplus'; diff --git a/src/sections/userpreset-browser.ts b/src/sections/userpreset-browser.ts index af47278..467f4d2 100644 --- a/src/sections/userpreset-browser.ts +++ b/src/sections/userpreset-browser.ts @@ -290,9 +290,12 @@ export class UserPresetBrowser extends FavBrowserBase { const result = JSON.parse(JSON.stringify(this.config.userPresets || [])) as IUserPreset[]; if (result) { - // set where the configuration items were loaded from. + // set where the configuration items were loaded from, and + // replace nocache indicator if specified. + const noCacheKey = "nocache=" + getUtcNowTimestamp(); result.forEach(item => { item.origin = "card config"; + item.image_url = (item.image_url || "").replace("{nocache}", noCacheKey); }); // append results to media list. @@ -333,9 +336,12 @@ export class UserPresetBrowser extends FavBrowserBase { const responseObj = response as IUserPreset[] if (responseObj) { - // set where the configuration items were loaded from. + // set where the configuration items were loaded from, and + // replace nocache indicator if specified. + const noCacheKey = "nocache=" + getUtcNowTimestamp(); responseObj.forEach(item => { item.origin = this.config.userPresetsFile as string; + item.image_url = (item.image_url || "").replace("{nocache}", noCacheKey); }); // append results to media list. From 188fb7e7c0db1d1b0edbd6c88faeefeb6492814d Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Sun, 22 Dec 2024 13:16:08 -0600 Subject: [PATCH 13/17] [ 1.0.24 ] * Added new userpreset type `filtersection`, which can be used to quickly display a section with the specified filter criteria applied. More info can be found on the [wiki docs](https://github.com/thlucas1/spotifyplus_card/wiki/Configuration-Options#userpresets-filter-section-media). --- CHANGELOG.md | 6 +- LICENSE | 2 +- SpotifyPlusCard.njsproj | 3 + src/card.ts | 125 ++++++++++++++++++++++++--- src/constants.ts | 2 +- src/events/filter-section-media.ts | 60 +++++++++++++ src/sections/fav-browser-base.ts | 22 +++++ src/sections/userpreset-browser.ts | 27 ++++++ src/types/spotifyplus/user-preset.ts | 12 +++ 9 files changed, 246 insertions(+), 13 deletions(-) create mode 100644 src/events/filter-section-media.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f10b054..38d7043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,15 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.24 ] - 2024/12/22 + + * Added new userpreset type `filtersection`, which can be used to quickly display a section with the specified filter criteria applied. More info can be found on the [wiki docs](https://github.com/thlucas1/spotifyplus_card/wiki/Configuration-Options#userpresets-filter-section-media). + ###### [ 1.0.23 ] - 2024/12/20 * This release requires the SpotifyPlus Integration v1.0.73+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. * Fixed hidden volume controls, which was caused by a bug introduced with v1.0.20. - * Added the ability to disable image caching for userpreset images. More info can be found on the [wiki docs](https://github.com/thlucas1/spotifyplus_card/wiki/Configuration-Options#userpresets-image-url-caching.) + * Added the ability to disable image caching for userpreset images. More info can be found on the [wiki docs](https://github.com/thlucas1/spotifyplus_card/wiki/Configuration-Options#userpresets-image-url-caching). ###### [ 1.0.22 ] - 2024/12/18 diff --git a/LICENSE b/LICENSE index f13aa69..6eb993b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 - 2024 Todd Lucas @thlucas1 +Copyright (c) 2019 - 2025 Todd Lucas @thlucas1 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/SpotifyPlusCard.njsproj b/SpotifyPlusCard.njsproj index be987ff..ace4a44 100644 --- a/SpotifyPlusCard.njsproj +++ b/SpotifyPlusCard.njsproj @@ -144,6 +144,9 @@ Code + + Code + diff --git a/src/card.ts b/src/card.ts index f274135..708d01e 100644 --- a/src/card.ts +++ b/src/card.ts @@ -25,6 +25,7 @@ import './editor/editor'; // our imports. import { SEARCH_MEDIA, SearchMediaEventArgs } from './events/search-media'; +import { FILTER_SECTION_MEDIA, FilterSectionMediaEventArgs } from './events/filter-section-media'; import { CATEGORY_DISPLAY, CategoryDisplayEventArgs } from './events/category-display'; import { EDITOR_CONFIG_AREA_SELECTED, EditorConfigAreaSelectedEventArgs } from './events/editor-config-area-selected'; import { PROGRESS_STARTED } from './events/progress-started'; @@ -36,7 +37,18 @@ import { CardConfig } from './types/card-config'; import { CustomImageUrls } from './types/custom-image-urls'; import { SearchMediaTypes } from './types/search-media-types'; import { SearchBrowser } from './sections/search-media-browser'; +import { FavBrowserBase } from './sections/fav-browser-base'; +import { AlbumFavBrowser } from './sections/album-fav-browser'; +import { ArtistFavBrowser } from './sections/artist-fav-browser'; +import { AudiobookFavBrowser } from './sections/audiobook-fav-browser'; import { CategoryBrowser } from './sections/category-browser'; +import { DeviceBrowser } from './sections/device-browser'; +import { EpisodeFavBrowser } from './sections/episode-fav-browser'; +import { PlaylistFavBrowser } from './sections/playlist-fav-browser'; +import { RecentBrowser } from './sections/recent-browser'; +import { ShowFavBrowser } from './sections/show-fav-browser'; +import { TrackFavBrowser } from './sections/track-fav-browser'; +import { UserPresetBrowser } from './sections/userpreset-browser'; import { formatTitleInfo, removeSpecialChars } from './utils/media-browser-utils'; import { BRAND_LOGO_IMAGE_BASE64, BRAND_LOGO_IMAGE_SIZE, FOOTER_ICON_SIZE_DEFAULT } from './constants'; import { @@ -98,8 +110,19 @@ export class Card extends LitElement { @state() private cancelLoader!: boolean; @state() private playerId!: string; + // card section references. @query("#elmSearchMediaBrowserForm", false) private elmSearchMediaBrowserForm!: SearchBrowser; @query("#elmCategoryBrowserForm", false) private elmCategoryBrowserForm!: CategoryBrowser; + @query("#elmAlbumFavBrowserForm", false) private elmAlbumFavBrowserForm!: AlbumFavBrowser; + @query("#elmArtistFavBrowserForm", false) private elmArtistFavBrowserForm!: ArtistFavBrowser; + @query("#elmAudiobookFavBrowserForm", false) private elmAudiobookFavBrowserForm!: AudiobookFavBrowser; + @query("#elmDeviceBrowserForm", false) private elmDeviceBrowserForm!: DeviceBrowser; + @query("#elmEpisodeFavBrowserForm", false) private elmEpisodeFavBrowserForm!: EpisodeFavBrowser; + @query("#elmPlaylistFavBrowserForm", false) private elmPlaylistFavBrowserForm!: PlaylistFavBrowser; + @query("#elmRecentBrowserForm", false) private elmRecentBrowserForm!: RecentBrowser; + @query("#elmShowFavBrowserForm", false) private elmShowFavBrowserForm!: ShowFavBrowser; + @query("#elmTrackFavBrowserForm", false) private elmTrackFavBrowserForm!: TrackFavBrowser; + @query("#elmUserPresetBrowserForm", false) private elmUserPresetBrowserForm!: UserPresetBrowser; /** Indicates if createStore method is executing for the first time (true) or not (false). */ private isFirstTimeSetup: boolean = true; @@ -168,19 +191,19 @@ export class Card extends LitElement { ${ this.playerId ? choose(this.section, [ - [Section.ALBUM_FAVORITES, () => html``], - [Section.ARTIST_FAVORITES, () => html``], - [Section.AUDIOBOOK_FAVORITES, () => html``], + [Section.ALBUM_FAVORITES, () => html``], + [Section.ARTIST_FAVORITES, () => html``], + [Section.AUDIOBOOK_FAVORITES, () => html``], [Section.CATEGORYS, () => html``], - [Section.DEVICES, () => html``], - [Section.EPISODE_FAVORITES, () => html``], + [Section.DEVICES, () => html``], + [Section.EPISODE_FAVORITES, () => html``], [Section.PLAYER, () => html``], - [Section.PLAYLIST_FAVORITES, () => html``], - [Section.RECENTS, () => html``], + [Section.PLAYLIST_FAVORITES, () => html``], + [Section.RECENTS, () => html``], [Section.SEARCH_MEDIA, () => html``], - [Section.SHOW_FAVORITES, () => html``], - [Section.TRACK_FAVORITES, () => html``], - [Section.USERPRESETS, () => html``], + [Section.SHOW_FAVORITES, () => html``], + [Section.TRACK_FAVORITES, () => html``], + [Section.USERPRESETS, () => html``], [Section.UNDEFINED, () => html`
SpotifyPlus card configuration error.
Please configure section(s) to display.
`], ]) : html`
Welcome to the SpotifyPlus media player card.
Start by configuring a media player entity.
` @@ -418,6 +441,7 @@ export class Card extends LitElement { this.addEventListener(PROGRESS_STARTED, this.onProgressStartedEventHandler); this.addEventListener(SEARCH_MEDIA, this.onSearchMediaEventHandler); this.addEventListener(CATEGORY_DISPLAY, this.onCategoryDisplayEventHandler); + this.addEventListener(FILTER_SECTION_MEDIA, this.onFilterSectionMediaEventHandler); // only add the following events if card configuration is being edited. if (isCardInEditPreview(this)) { @@ -447,6 +471,7 @@ export class Card extends LitElement { this.removeEventListener(PROGRESS_STARTED, this.onProgressStartedEventHandler); this.removeEventListener(SEARCH_MEDIA, this.onSearchMediaEventHandler); this.removeEventListener(CATEGORY_DISPLAY, this.onCategoryDisplayEventHandler); + this.removeEventListener(FILTER_SECTION_MEDIA, this.onFilterSectionMediaEventHandler); // the following event is only added when the card configuration editor is created. // always remove the following events, as isCardInEditPreview() can sometimes @@ -658,6 +683,86 @@ export class Card extends LitElement { } + /** + * Handles the `FILTER_SECTION_MEDIA` event. + * This will show the specified section, and apply the specified filter criteria + * passed in the event arguments. + * + * @param ev Event definition and arguments. + */ + protected onFilterSectionMediaEventHandler = (ev: Event) => { + + // map event arguments. + const evArgs = (ev as CustomEvent).detail as FilterSectionMediaEventArgs; + + // validate section id. + const enumValues: string[] = Object.values(Section); + if (!enumValues.includes(evArgs.section || "")) { + debuglog("%onFilterSectionMediaEventHandler - Ignoring Filter request; section is not a valid Section enum value:\n%s", + "color:red", + JSON.stringify(evArgs, null, 2), + ); + } + + // is section activated? if so, then select it. + if (this.config.sections?.includes(evArgs.section as Section)) { + + // show the search section. + this.section = evArgs.section as Section; + this.store.section = this.section; + + // wait just a bit before executing the search. + setTimeout(() => { + + if (debuglog.enabled) { + debuglog("onFilterSectionMediaEventHandler - executing filter:\n%s", + JSON.stringify(evArgs, null, 2), + ); + } + + // reference the section browser. + let browserBase: FavBrowserBase; + if (evArgs.section == Section.ALBUM_FAVORITES) { + browserBase = this.elmAlbumFavBrowserForm; + } else if (evArgs.section == Section.ARTIST_FAVORITES) { + browserBase = this.elmArtistFavBrowserForm; + } else if (evArgs.section == Section.AUDIOBOOK_FAVORITES) { + browserBase = this.elmAudiobookFavBrowserForm; + } else if (evArgs.section == Section.DEVICES) { + browserBase = this.elmDeviceBrowserForm; + } else if (evArgs.section == Section.EPISODE_FAVORITES) { + browserBase = this.elmEpisodeFavBrowserForm; + } else if (evArgs.section == Section.PLAYLIST_FAVORITES) { + browserBase = this.elmPlaylistFavBrowserForm; + } else if (evArgs.section == Section.RECENTS) { + browserBase = this.elmRecentBrowserForm; + } else if (evArgs.section == Section.SHOW_FAVORITES) { + browserBase = this.elmShowFavBrowserForm; + } else if (evArgs.section == Section.TRACK_FAVORITES) { + browserBase = this.elmTrackFavBrowserForm; + } else if (evArgs.section == Section.USERPRESETS) { + browserBase = this.elmUserPresetBrowserForm; + } else { + return; + } + + // execute the filter. + browserBase.filterSectionMedia(evArgs); + //super.requestUpdate(); + + }, 50); + + } else { + + // section is not activated; cannot search. + debuglog("%onFilterSectionMediaEventHandler - Filter section is not enabled; ignoring filter request:\n%s", + "color:red", + JSON.stringify(evArgs, null, 2), + ); + } + } + + /** * Handles the `SEARCH_MEDIA` event. * This will execute a search on the specified criteria passed in the event arguments. diff --git a/src/constants.ts b/src/constants.ts index e97fdd0..6d5e980 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.23'; +export const CARD_VERSION = '1.0.24'; /** SpotifyPlus integration domain identifier. */ export const DOMAIN_SPOTIFYPLUS = 'spotifyplus'; diff --git a/src/events/filter-section-media.ts b/src/events/filter-section-media.ts new file mode 100644 index 0000000..38f1f71 --- /dev/null +++ b/src/events/filter-section-media.ts @@ -0,0 +1,60 @@ +import { DOMAIN_SPOTIFYPLUS } from '../constants'; + +/** + * Uniquely identifies the event. + * */ +export const FILTER_SECTION_MEDIA = DOMAIN_SPOTIFYPLUS + '-card-filter-section-media'; + + +/** + * Event arguments. + */ +export class FilterSectionMediaEventArgs { + + /** + * Section type to filter. + */ + public section: string; + + /** + * Filter criteria. + */ + public filterCriteria: string; + + + /** + * Initializes a new instance of the class. + * + * @param section Section to be filtered. + * @param filterCriteria Filter criteria that will be applied to the specified filter section. + */ + constructor( + section: string | undefined | null, + filterCriteria: string | undefined | null = null, + ) { + this.section = section || ""; + this.filterCriteria = filterCriteria || ""; + } +} + + +/** + * Event constructor. + * + * @param section Section to be filtered. + * @param filterCriteria Filter criteria that will be applied to the specified filter section. + */ +export function FilterSectionMediaEvent( + section: string | undefined | null, + filterCriteria: string | undefined | null, +) { + + const args = new FilterSectionMediaEventArgs(section); + args.filterCriteria = (filterCriteria || "").trim(); + + return new CustomEvent(FILTER_SECTION_MEDIA, { + bubbles: true, + composed: true, + detail: args, + }); +} diff --git a/src/sections/fav-browser-base.ts b/src/sections/fav-browser-base.ts index 33fc6a3..b3fcaf3 100644 --- a/src/sections/fav-browser-base.ts +++ b/src/sections/fav-browser-base.ts @@ -17,6 +17,7 @@ import { SpotifyPlusService } from '../services/spotifyplus-service'; import { storageService } from '../decorators/storage'; import { truncateMediaList } from '../utils/media-browser-utils'; import { isCardInEditPreview, loadHaFormLazyControls } from '../utils/utils'; +import { FilterSectionMediaEventArgs } from '../events/filter-section-media'; import { ProgressEndedEvent } from '../events/progress-ended'; import { ProgressStartedEvent } from '../events/progress-started'; import { DOMAIN_SPOTIFYPLUS } from '../constants'; @@ -326,6 +327,27 @@ export class FavBrowserBase extends LitElement { } + /** + * Execute filter based on passed arguments. + */ + public filterSectionMedia(args: FilterSectionMediaEventArgs): void { + + if (debuglog.enabled) { + debuglog("filterSectionMedia - filtering section media:\n%s", + JSON.stringify(args, null, 2), + ); + } + + // apply filter criteria. + this.filterCriteria = args.filterCriteria; + //this.requestUpdate(); + + // execute the search. + //this.updateMediaList(this.player); + + } + + /** * Loads values from persistant storage. */ diff --git a/src/sections/userpreset-browser.ts b/src/sections/userpreset-browser.ts index 467f4d2..4c3a186 100644 --- a/src/sections/userpreset-browser.ts +++ b/src/sections/userpreset-browser.ts @@ -1,3 +1,8 @@ +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from '../constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":userpreset-browser"); + // lovelace card imports. import { html, TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; @@ -13,6 +18,7 @@ import { formatTitleInfo } from '../utils/media-browser-utils'; import { getUtcNowTimestamp } from '../utils/utils'; import { IUserPreset } from '../types/spotifyplus/user-preset'; import { CategoryDisplayEvent } from '../events/category-display'; +import { FilterSectionMediaEvent } from '../events/filter-section-media'; import { ALERT_ERROR_SPOTIFY_PREMIUM_REQUIRED } from '../constants'; @@ -104,12 +110,33 @@ export class UserPresetBrowser extends FavBrowserBase { */ protected override onItemSelected(evArgs: CustomEvent) { + if (debuglog.enabled) { + debuglog("onItemSelected - media item selected:\n%s", + JSON.stringify(evArgs.detail, null, 2), + ); + } + // is this a recommendations type? if (evArgs.detail.type == "recommendations") { const mediaItem = evArgs.detail as IUserPreset; this.PlayTrackRecommendations(mediaItem); + } else if (evArgs.detail.type == "filtersection") { + + const preset = evArgs.detail as IUserPreset; + + // validate filter section name. + const enumValues: string[] = Object.values(Section); + if (!enumValues.includes(preset.filter_section || "")) { + //if (Object.values(Section) as string[]).includes(preset.filter_section || "") { + this.alertErrorSet("Preset filter_section \"" + preset.filter_section + "\" is not a valid section identifier."); + return; + } + + // fire event. + this.dispatchEvent(FilterSectionMediaEvent(preset.filter_section, preset.filter_criteria)); + } else if (evArgs.detail.type == "category") { const preset = evArgs.detail as IUserPreset; diff --git a/src/types/spotifyplus/user-preset.ts b/src/types/spotifyplus/user-preset.ts index 210dd97..4e7d547 100644 --- a/src/types/spotifyplus/user-preset.ts +++ b/src/types/spotifyplus/user-preset.ts @@ -59,6 +59,18 @@ export interface IUserPreset { */ shuffle?: boolean | null; + /** + * Filter criteria that will be applied to the specified filter section. + * This property should only be populated for type = "filtersection". + */ + filter_criteria?: string | null; + + /** + * Section to be filtered. + * This property should only be populated for type = "filtersection". + */ + filter_section?: string | null; + } From 82971d136a8b0804a40c4822b072f8afe1595a03 Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Mon, 23 Dec 2024 11:06:01 -0600 Subject: [PATCH 14/17] [ 1.0.25 ] * Ability to play track favorites starting from selected favorite track, which will now automatically add the following tracks (up to 50) to the player queue. Prior to this, only the selected track would play and then play would stop. Note that the 50 track limitation is a Spotify Web API limit. --- CHANGELOG.md | 4 ++ src/constants.ts | 2 +- src/sections/track-fav-browser.ts | 83 +++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38d7043..449ac21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.25 ] - 2024/12/23 + + * Ability to play track favorites starting from selected favorite track, which will now automatically add the following tracks (up to 50) to the player queue. Prior to this, only the selected track would play and then play would stop. Note that the 50 track limitation is a Spotify Web API limit. + ###### [ 1.0.24 ] - 2024/12/22 * Added new userpreset type `filtersection`, which can be used to quickly display a section with the specified filter criteria applied. More info can be found on the [wiki docs](https://github.com/thlucas1/spotifyplus_card/wiki/Configuration-Options#userpresets-filter-section-media). diff --git a/src/constants.ts b/src/constants.ts index 6d5e980..86d1997 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.24'; +export const CARD_VERSION = '1.0.25'; /** SpotifyPlus integration domain identifier. */ export const DOMAIN_SPOTIFYPLUS = 'spotifyplus'; diff --git a/src/sections/track-fav-browser.ts b/src/sections/track-fav-browser.ts index d4ae9b4..36342ae 100644 --- a/src/sections/track-fav-browser.ts +++ b/src/sections/track-fav-browser.ts @@ -1,3 +1,8 @@ +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from '../constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":track-fav-browser"); + // lovelace card imports. import { html, TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; @@ -96,6 +101,84 @@ export class TrackFavBrowser extends FavBrowserBase { } + /** + * Handles the `item-selected` event fired when a media browser item is clicked. + * + * @param evArgs Event arguments that contain the media item that was clicked on. + */ + protected override onItemSelected(evArgs: CustomEvent) { + + if (debuglog.enabled) { + debuglog("onItemSelected - media item selected:\n%s", + JSON.stringify(evArgs.detail, null, 2), + ); + } + + try { + + // set media item reference. + const mediaItem = evArgs.detail as ITrack; + + // build track uri list from favorites list. + // note that Spotify web api can only play 50 tracks max. + const maxItems = 50; + const uris = new Array(); + const names = new Array(); + let count = 0; + let startFound = false; + + for (const item of (this.mediaList || [])) { + + if (item.uri == mediaItem.uri) { + startFound = true; + } + + if (startFound) { + uris.push(item.uri); + names.push(item.name); + count += 1; + if (count >= maxItems) { + break; + } + } + + } + + // trace. + if (debuglog.enabled) { + debuglog("onItemSelected - tracks to play:\n%s", + JSON.stringify(names, null, 2), + ); + } + + // show progress indicator. + this.progressShow(); + + // play media item. + this.spotifyPlusService.PlayerMediaPlayTracks(this.player.id, uris.join(",")); + + // show player section. + this.store.card.SetSection(Section.PLAYER); + + } + catch (error) { + + // set error message and reset scroll position to zero so the message is displayed. + this.alertErrorSet("Could not play media item. " + (error as Error).message); + this.mediaBrowserContentElement.scrollTop = 0; + + } + finally { + + // hide progress indicator. + this.progressHide(); + + } + + + } + + /** * Updates the mediaList display. */ From 522c04d7408eb035e8c62c4aa83f72cd2685860b Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Tue, 24 Dec 2024 00:32:03 -0600 Subject: [PATCH 15/17] [ 1.0.26 ] * Added `playerBackgroundImageSize` config option that specifies the size of the player background image. Defaults to "100% 100%"; More info can be found on the [wiki docs](https://github.com/thlucas1/spotifyplus_card/wiki/Configuration-Options#playerbackgroundimagesize). --- CHANGELOG.md | 4 ++ SpotifyPlusCard.njsproj | 9 ++-- src/components/player-body-track.ts | 6 +++ src/constants.ts | 5 ++- src/editor/player-editor.ts | 12 ++++-- src/editor/player-general-editor.ts | 66 +++++++++++++++++++++++++++++ src/sections/player.ts | 56 +++++++++++++----------- src/types/card-config.ts | 10 +++++ 8 files changed, 134 insertions(+), 34 deletions(-) create mode 100644 src/editor/player-general-editor.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 449ac21..8cc1a2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.26 ] - 2024/12/24 + + * Added `playerBackgroundImageSize` config option that specifies the size of the player background image. Defaults to "100% 100%"; More info can be found on the [wiki docs](https://github.com/thlucas1/spotifyplus_card/wiki/Configuration-Options#playerbackgroundimagesize). + ###### [ 1.0.25 ] - 2024/12/23 * Ability to play track favorites starting from selected favorite track, which will now automatically add the following tracks (up to 50) to the player queue. Prior to this, only the selected track would play and then play would stop. Note that the 50 track limitation is a Spotify Web API limit. diff --git a/SpotifyPlusCard.njsproj b/SpotifyPlusCard.njsproj index ace4a44..b90a6ab 100644 --- a/SpotifyPlusCard.njsproj +++ b/SpotifyPlusCard.njsproj @@ -141,12 +141,9 @@ - - Code - - - Code - + + + diff --git a/src/components/player-body-track.ts b/src/components/player-body-track.ts index 28518bc..66f9226 100644 --- a/src/components/player-body-track.ts +++ b/src/components/player-body-track.ts @@ -429,6 +429,12 @@ export class PlayerBodyTrack extends PlayerBodyBase { try { + // if editing the card, then don't bother updating actions as we will not + // display the actions dialog. + if (this.isCardInEditPreview) { + return false; + } + // process actions that don't require a progress indicator. if (action == Actions.AlbumCopyPresetToClipboard) { diff --git a/src/constants.ts b/src/constants.ts index 86d1997..0328206 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.25'; +export const CARD_VERSION = '1.0.26'; /** SpotifyPlus integration domain identifier. */ export const DOMAIN_SPOTIFYPLUS = 'spotifyplus'; @@ -49,6 +49,9 @@ 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'; +/** default size of the player background image. */ +export const PLAYER_BACKGROUND_IMAGE_SIZE_DEFAULT = "100% 100%"; + export const listStyle = css` .list { diff --git a/src/editor/player-editor.ts b/src/editor/player-editor.ts index 1972bc5..2bdf424 100644 --- a/src/editor/player-editor.ts +++ b/src/editor/player-editor.ts @@ -6,24 +6,26 @@ import { choose } from 'lit/directives/choose.js'; // our imports. import { BaseEditor } from './base-editor'; import './editor-form'; +import './player-general-editor'; import './player-header-editor'; import './player-controls-editor'; import './player-volume-editor'; /** Configuration area editor sections enum. */ enum ConfigArea { + GENERAL = 'General', HEADER = 'Header', CONTROLS = 'Controls', VOLUME = 'Volume', } /** Configuration area editor section keys array. */ -const { HEADER, CONTROLS, VOLUME } = ConfigArea; +const { GENERAL, HEADER, CONTROLS, VOLUME } = ConfigArea; class PlayerSettingsEditor extends BaseEditor { - @state() private configArea = HEADER; + @state() private configArea = GENERAL; /** * Invoked on each update to perform rendering tasks. @@ -41,7 +43,7 @@ class PlayerSettingsEditor extends BaseEditor { Settings that control the Player section look and feel
- ${[HEADER, CONTROLS, VOLUME].map( + ${[GENERAL, HEADER, CONTROLS, VOLUME].map( (configArea) => html` html``, + ], [ HEADER, () => html``, diff --git a/src/editor/player-general-editor.ts b/src/editor/player-general-editor.ts new file mode 100644 index 0000000..f657b7d --- /dev/null +++ b/src/editor/player-general-editor.ts @@ -0,0 +1,66 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { Section } from '../types/section'; +import { PLAYER_BACKGROUND_IMAGE_SIZE_DEFAULT } from '../constants'; + + +const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'playerBackgroundImageSize', + label: 'Size of the player background image', + help: 'default is "' + PLAYER_BACKGROUND_IMAGE_SIZE_DEFAULT + '"', + required: false, + type: 'string', + }, +]; + + +class PlayerGeneralSettingsEditor extends BaseEditor { + + /** + * 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 { + + // ensure store is created. + super.createStore(); + + // render html. + return html` +
+ Player General area settings +
+ + `; + } + + + /** + * Style definitions used by this TemplateResult. + */ + static get styles() { + return css` + .schema-title { + margin: 0.4rem 0; + text-align: left; + font-size: 1rem; + color: var(--secondary-text-color); + } + `; + } + +} + +customElements.define('spc-player-general-editor', PlayerGeneralSettingsEditor); diff --git a/src/sections/player.ts b/src/sections/player.ts index 5c490cf..2fdf3d0 100644 --- a/src/sections/player.ts +++ b/src/sections/player.ts @@ -1,7 +1,7 @@ // lovelace card imports. import { css, html, LitElement, PropertyValues, TemplateResult } from 'lit'; import { customElement, property, state } from "lit/decorators.js"; -import { styleMap } from 'lit-html/directives/style-map.js'; +import { styleMap, StyleInfo } from 'lit-html/directives/style-map.js'; // ** IMPORTANT - Vibrant notes: // ensure that you have "compilerOptions"."lib": [ ... , "WebWorker" ] specified @@ -125,6 +125,7 @@ export class Player extends LitElement implements playerAlerts { * style definitions used by this component. * */ static get styles() { + return css` .hoverable:focus, @@ -148,7 +149,7 @@ export class Player extends LitElement implements playerAlerts { /*background-color: #000000;*/ background-position: center; background-repeat: no-repeat; - background-size: var(--spc-player-background-size); + background-size: var(--spc-player-background-size, 100% 100%); /* PLAYER_BACKGROUND_IMAGE_SIZE_DEFAULT */ text-align: -webkit-center; height: 100%; width: 100%; @@ -215,12 +216,16 @@ export class Player extends LitElement implements playerAlerts { */ private styleBackgroundImage() { - // stretch the background cover art to fit the entire player. - //const backgroundSize = 'cover'; - //const backgroundSize = 'contain'; - let backgroundSize = '100% 100%'; - if (this.config.width == 'fill') { - // if in fill mode, then do not stretch the image. + // get default player background size. + let backgroundSize: string | undefined; + + // allow user configuration to override background size. + if (this.config.playerBackgroundImageSize) { + backgroundSize = this.config.playerBackgroundImageSize; + } + + // if not configured AND in fill mode, then do not stretch the background image. + if ((!backgroundSize) && (this.config.width == 'fill')) { backgroundSize = 'contain'; } @@ -262,22 +267,25 @@ export class Player extends LitElement implements playerAlerts { // set player controls and volume controls icon size. const playerControlsIconSize = this.config.playerControlsIconSize || PLAYER_CONTROLS_ICON_SIZE_DEFAULT; - return styleMap({ - 'background-image': `url(${imageUrl})`, - '--spc-player-background-size': `${backgroundSize}`, - '--spc-player-header-bg-color': `${headerBackgroundColor}`, - '--spc-player-header-color': `#ffffff`, - '--spc-player-controls-bg-color': `${controlsBackgroundColor}`, - '--spc-player-controls-color': `#ffffff`, - '--spc-player-controls-icon-size': `${playerControlsIconSize}`, - '--spc-player-controls-icon-button-size': `var(--spc-player-controls-icon-size, ${PLAYER_CONTROLS_ICON_SIZE_DEFAULT}) + 0.75rem`, - '--spc-player-palette-vibrant': `${this._colorPaletteVibrant}`, - '--spc-player-palette-muted': `${this._colorPaletteMuted}`, - '--spc-player-palette-darkvibrant': `${this._colorPaletteDarkVibrant}`, - '--spc-player-palette-darkmuted': `${this._colorPaletteDarkMuted}`, - '--spc-player-palette-lightvibrant': `${this._colorPaletteLightVibrant}`, - '--spc-player-palette-lightmuted': `${this._colorPaletteLightMuted}`, - }); + // build style info object. + const styleInfo: StyleInfo = {}; + styleInfo['background-image'] = `url(${imageUrl})`; + if (backgroundSize) + styleInfo['--spc-player-background-size'] = `${backgroundSize}`; + styleInfo['--spc-player-header-bg-color'] = `${headerBackgroundColor}`; + styleInfo['--spc-player-header-color'] = `#ffffff`; + styleInfo['--spc-player-controls-bg-color'] = `${controlsBackgroundColor} `; + styleInfo['--spc-player-controls-color'] = `#ffffff`; + styleInfo['--spc-player-controls-icon-size'] = `${playerControlsIconSize}`; + styleInfo['--spc-player-controls-icon-button-size'] = `var(--spc-player-controls-icon-size, ${PLAYER_CONTROLS_ICON_SIZE_DEFAULT}) + 0.75rem`; + styleInfo['--spc-player-palette-vibrant'] = `${this._colorPaletteVibrant}`; + styleInfo['--spc-player-palette-muted'] = `${this._colorPaletteMuted}`; + styleInfo['--spc-player-palette-darkvibrant'] = `${this._colorPaletteDarkVibrant}`; + styleInfo['--spc-player-palette-darkmuted'] = `${this._colorPaletteDarkMuted}`; + styleInfo['--spc-player-palette-lightvibrant'] = `${this._colorPaletteLightVibrant}`; + styleInfo['--spc-player-palette-lightmuted'] = `${this._colorPaletteLightMuted}`; + return styleMap(styleInfo); + } diff --git a/src/types/card-config.ts b/src/types/card-config.ts index b63dc77..02d3215 100644 --- a/src/types/card-config.ts +++ b/src/types/card-config.ts @@ -309,6 +309,16 @@ export interface CardConfig extends LovelaceCardConfig { */ episodeFavBrowserItemsSortTitle?: boolean; + /** + * Size of the player background image. + * Suggested values: + * - "100% 100%" image size is 100%, stretching to fill available space. + * - "contain" image is contained in the boundaries without stretching. + * - "cover" image covers the entire background, stretching to fill available space. + * Default is "100% 100%". + */ + playerBackgroundImageSize?: string; + /** * Title displayed in the header area of the Player section form. * Omit this parameter to hide the title display area. From 73045e6dd7553a286df1aa7fbd302da17ea98c3b Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Sat, 28 Dec 2024 11:13:55 -0600 Subject: [PATCH 16/17] [ 1.0.27 ] * Updated Devices section to remove device entries that wish to be hidden as specified by SpotifyPlus integration configuration "hide devices" option. * Added ability to play recently played tracks starting from selected track, which will now automatically add the following tracks (up to 50) to the player queue. Prior to this, only the selected track would play and then play would stop. Note that the 50 track limitation is a Spotify Web API limit. * Added ability to play player queue tracks starting from selected track, which will now automatically add the following tracks (up to 50) to the player queue. Prior to this, only the selected track would play and then play would stop. Note that the 50 track limitation is a Spotify Web API limit. --- CHANGELOG.md | 8 +- src/components/player-body-queue.ts | 26 +++++-- src/components/player-controls.ts | 22 +++--- src/constants.ts | 2 +- src/sections/device-browser.ts | 5 +- src/sections/recent-browser.ts | 56 +++++++++++++- src/sections/track-fav-browser.ts | 43 ++--------- src/services/spotifyplus-service.ts | 14 +++- .../spotifyplus-hass-entity-attributes.ts | 5 ++ src/utils/media-browser-utils.ts | 76 +++++++++++++++++++ 10 files changed, 197 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc1a2c..710b876 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,19 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.27 ] - 2024/12/28 + + * Updated Devices section to remove device entries that wish to be hidden as specified by SpotifyPlus integration configuration "hide devices" option. + * Added ability to play recently played tracks starting from selected track, which will now automatically add the following tracks (up to 50) to the player queue. Prior to this, only the selected track would play and then play would stop. Note that the 50 track limitation is a Spotify Web API limit. + * Added ability to play player queue tracks starting from selected track, which will now automatically add the following tracks (up to 50) to the player queue. Prior to this, only the selected track would play and then play would stop. Note that the 50 track limitation is a Spotify Web API limit. + ###### [ 1.0.26 ] - 2024/12/24 * Added `playerBackgroundImageSize` config option that specifies the size of the player background image. Defaults to "100% 100%"; More info can be found on the [wiki docs](https://github.com/thlucas1/spotifyplus_card/wiki/Configuration-Options#playerbackgroundimagesize). ###### [ 1.0.25 ] - 2024/12/23 - * Ability to play track favorites starting from selected favorite track, which will now automatically add the following tracks (up to 50) to the player queue. Prior to this, only the selected track would play and then play would stop. Note that the 50 track limitation is a Spotify Web API limit. + * Added ability to play track favorites starting from selected favorite track, which will now automatically add the following tracks (up to 50) to the player queue. Prior to this, only the selected track would play and then play would stop. Note that the 50 track limitation is a Spotify Web API limit. ###### [ 1.0.24 ] - 2024/12/22 diff --git a/src/components/player-body-queue.ts b/src/components/player-body-queue.ts index 8ef1355..7fa2dea 100644 --- a/src/components/player-body-queue.ts +++ b/src/components/player-body-queue.ts @@ -1,3 +1,8 @@ +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from '../constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":player-body-queue"); + // lovelace card imports. import { css, html, TemplateResult } from 'lit'; import { state } from 'lit/decorators.js'; @@ -9,14 +14,11 @@ import { 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 { getMediaListTrackUrisRemaining } from '../utils/media-browser-utils.js'; 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"); +import { IPlayerQueueInfo } from '../types/spotifyplus/player-queue-info'; +import { ITrack } from '../types/spotifyplus/track'; /** * Track actions. @@ -214,11 +216,19 @@ export class PlayerBodyQueue extends PlayerBodyBase { } else if (action == Actions.TrackPlay) { - await this.spotifyPlusService.Card_PlayMediaBrowserItem(this.player, item); + // build track uri list from media list. + const { uris } = getMediaListTrackUrisRemaining(this.queueInfo?.queue as ITrack[], item); + + // play media item. + this.spotifyPlusService.PlayerMediaPlayTracks(this.player.id, uris.join(",")); this.progressHide(); - } + } else { + // no action selected - hide progress indicator. + this.progressHide(); + + } return true; } diff --git a/src/components/player-controls.ts b/src/components/player-controls.ts index 1defbad..ee6cb19 100644 --- a/src/components/player-controls.ts +++ b/src/components/player-controls.ts @@ -34,7 +34,7 @@ import Debug from 'debug/src/browser.js'; import { DEBUG_APP_NAME } from '../constants'; const debuglog = Debug(DEBUG_APP_NAME + ":player-controls"); -const { NEXT_TRACK, PAUSE, PLAY, PREVIOUS_TRACK, REPEAT_SET, SHUFFLE_SET, TURN_ON } = MediaPlayerEntityFeature; +const { NEXT_TRACK, PAUSE, PLAY, PREVIOUS_TRACK, REPEAT_SET, SHUFFLE_SET, TURN_ON, TURN_OFF } = MediaPlayerEntityFeature; const ACTION_FAVES = 900000000000; const PLAY_QUEUE = 990000000000; @@ -102,6 +102,7 @@ class PlayerControls extends LitElement {
this.onClickAction(TURN_ON)} hide=${this.hideFeature(TURN_ON)} .path=${mdiPower} label="Turn On" style=${this.styleIcon(colorPower)}> + this.onClickAction(TURN_OFF)} hide=${this.hideFeature(TURN_OFF)} .path=${mdiPower} label="Turn Off">
`; @@ -458,9 +459,9 @@ class PlayerControls extends LitElement { await this.mediaControlService.shuffle_set(this.player, !this.player.attributes.shuffle); - //} else if (action == TURN_OFF) { + } else if (action == TURN_OFF) { - // this.mediaControlService.turn_off(this.player); + await this.mediaControlService.turn_off(this.player); } else if (action == TURN_ON) { @@ -554,14 +555,15 @@ class PlayerControls extends LitElement { return true; // hide icon } - //} else if (feature == TURN_OFF) { + } else if (feature == TURN_OFF) { - // if (this.player.supportsFeature(TURN_OFF)) { - // if (![MediaPlayerState.OFF, MediaPlayerState.UNKNOWN, MediaPlayerState.STANDBY].includes(this.player.state)) { - // return nothing; // show icon - // } - // return true; // hide icon - // } + // this should only be allowed if the player state is IDLE. + if (this.player.supportsFeature(TURN_OFF)) { + if ([MediaPlayerState.IDLE].includes(this.player.state)) { + return (this.config.playerVolumeControlsHidePower) ? true : nothing; + } + return true; // hide icon + } } diff --git a/src/constants.ts b/src/constants.ts index 0328206..1b116d2 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.26'; +export const CARD_VERSION = '1.0.27'; /** SpotifyPlus integration domain identifier. */ export const DOMAIN_SPOTIFYPLUS = 'spotifyplus'; diff --git a/src/sections/device-browser.ts b/src/sections/device-browser.ts index bdb3269..e83a1ac 100644 --- a/src/sections/device-browser.ts +++ b/src/sections/device-browser.ts @@ -204,8 +204,11 @@ export class DeviceBrowser extends FavBrowserBase { const refresh = this.refreshDeviceList || false; // refresh device list (defaults to cached list). const sortResult = true; // true to sort returned items; otherwise, false + // get source items to omit. + const sourceListHide = player.attributes.sp_source_list_hide || []; + // call the service to retrieve the media list. - this.spotifyPlusService.GetSpotifyConnectDevices(player.id, refresh, sortResult) + this.spotifyPlusService.GetSpotifyConnectDevices(player.id, refresh, sortResult, sourceListHide) .then(result => { // load media list results. diff --git a/src/sections/recent-browser.ts b/src/sections/recent-browser.ts index d2512bf..e79eab4 100644 --- a/src/sections/recent-browser.ts +++ b/src/sections/recent-browser.ts @@ -1,3 +1,8 @@ +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from '../constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":recent-browser"); + // lovelace card imports. import { html, TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; @@ -9,7 +14,7 @@ import '../components/track-actions'; import { FavBrowserBase } from './fav-browser-base'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; -import { formatTitleInfo } from '../utils/media-browser-utils'; +import { formatTitleInfo, getMediaListTrackUrisRemaining } from '../utils/media-browser-utils'; import { getUtcNowTimestamp } from '../utils/utils'; import { GetTracks } from '../types/spotifyplus/track-page-saved'; import { ITrack } from '../types/spotifyplus/track'; @@ -96,6 +101,55 @@ export class RecentBrowser extends FavBrowserBase { } + + + /** + * Handles the `item-selected` event fired when a media browser item is clicked. + * + * @param evArgs Event arguments that contain the media item that was clicked on. + */ + protected override onItemSelected(evArgs: CustomEvent) { + + if (debuglog.enabled) { + debuglog("onItemSelected - media item selected:\n%s", + JSON.stringify(evArgs.detail, null, 2), + ); + } + + try { + + // show progress indicator. + this.progressShow(); + + // set media item reference. + const mediaItem = evArgs.detail as ITrack; + + // build track uri list from media list. + const { uris } = getMediaListTrackUrisRemaining(this.mediaList || [], mediaItem); + + // play media item. + this.spotifyPlusService.PlayerMediaPlayTracks(this.player.id, uris.join(",")); + + // show player section. + this.store.card.SetSection(Section.PLAYER); + + } + catch (error) { + + // set error message and reset scroll position to zero so the message is displayed. + this.alertErrorSet("Could not play media item. " + (error as Error).message); + this.mediaBrowserContentElement.scrollTop = 0; + + } + finally { + + // hide progress indicator. + this.progressHide(); + + } + } + + /** * Updates the mediaList display. */ diff --git a/src/sections/track-fav-browser.ts b/src/sections/track-fav-browser.ts index 36342ae..836e84f 100644 --- a/src/sections/track-fav-browser.ts +++ b/src/sections/track-fav-browser.ts @@ -14,7 +14,7 @@ import '../components/track-actions'; import { FavBrowserBase } from './fav-browser-base'; import { Section } from '../types/section'; import { MediaPlayer } from '../model/media-player'; -import { formatTitleInfo } from '../utils/media-browser-utils'; +import { formatTitleInfo, getMediaListTrackUrisRemaining } from '../utils/media-browser-utils'; import { getUtcNowTimestamp } from '../utils/utils'; import { GetTracks } from '../types/spotifyplus/track-page-saved'; import { ITrack } from '../types/spotifyplus/track'; @@ -116,43 +116,14 @@ export class TrackFavBrowser extends FavBrowserBase { try { + // show progress indicator. + this.progressShow(); + // set media item reference. const mediaItem = evArgs.detail as ITrack; - // build track uri list from favorites list. - // note that Spotify web api can only play 50 tracks max. - const maxItems = 50; - const uris = new Array(); - const names = new Array(); - let count = 0; - let startFound = false; - - for (const item of (this.mediaList || [])) { - - if (item.uri == mediaItem.uri) { - startFound = true; - } - - if (startFound) { - uris.push(item.uri); - names.push(item.name); - count += 1; - if (count >= maxItems) { - break; - } - } - - } - - // trace. - if (debuglog.enabled) { - debuglog("onItemSelected - tracks to play:\n%s", - JSON.stringify(names, null, 2), - ); - } - - // show progress indicator. - this.progressShow(); + // build track uri list from media list. + const { uris } = getMediaListTrackUrisRemaining(this.mediaList || [], mediaItem); // play media item. this.spotifyPlusService.PlayerMediaPlayTracks(this.player.id, uris.join(",")); @@ -174,8 +145,6 @@ export class TrackFavBrowser extends FavBrowserBase { this.progressHide(); } - - } diff --git a/src/services/spotifyplus-service.ts b/src/services/spotifyplus-service.ts index 59863c1..96997f0 100644 --- a/src/services/spotifyplus-service.ts +++ b/src/services/spotifyplus-service.ts @@ -2261,12 +2261,14 @@ export class SpotifyPlusService { * * @param refresh True to return real-time information from the spotify zeroconf api and update the cache; otherwise, False to just return the cached value. * @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 Zeroconf API. Default is true. + * @param source_list_hide List of device names to hide from the source list (colon delimited). * @returns A SpotifyConnectDevices object. */ public async GetSpotifyConnectDevices( entity_id: string, refresh: boolean | null = null, sort_result: boolean | null = null, + source_list_hide: Array | null = null, ): Promise { try { @@ -2302,8 +2304,18 @@ export class SpotifyPlusService { // get the "result" portion of the response, and convert it to a type. const responseObj = response["result"] as ISpotifyConnectDevices; - // set image_url property based on device type. + // process all items returned. if ((responseObj != null) && (responseObj.Items != null)) { + + // remove source items that are hidden (based on SpotifyPlus config options); + // we have to do this in reverse order, due to iteration of the array. + for (let i = responseObj.Items.length - 1; i >= 0; i--) { + if (source_list_hide?.includes(responseObj.Items[i].Name.toLowerCase())) { + responseObj.Items.splice(i, 1); + } + } + + // set image_url property based on device type. responseObj.Items.forEach(item => { // set image_url path using mdi icons for common sources. const sourceCompare = (item.Name || "").toLocaleLowerCase(); diff --git a/src/types/spotifyplus-hass-entity-attributes.ts b/src/types/spotifyplus-hass-entity-attributes.ts index 58dba0f..960b459 100644 --- a/src/types/spotifyplus-hass-entity-attributes.ts +++ b/src/types/spotifyplus-hass-entity-attributes.ts @@ -50,6 +50,11 @@ export declare type SpotifyPlusHassEntityAttributes = HassEntityAttributesMediaP */ sp_playlist_uri?: string; + /** + * List of device names (in lower-case) to hide from the source list. + */ + sp_source_list_hide?: Array; + /** * True if the track / episode has explicit content; otherwise, false. */ diff --git a/src/utils/media-browser-utils.ts b/src/utils/media-browser-utils.ts index 4984d2a..9ee4bbe 100644 --- a/src/utils/media-browser-utils.ts +++ b/src/utils/media-browser-utils.ts @@ -1,8 +1,14 @@ +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from '../constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":media-browser-utils"); + // our imports. import { MediaPlayer } from '../model/media-player'; import { CustomImageUrls } from '../types/custom-image-urls'; import { CardConfig } from '../types/card-config'; import { formatDateEpochSecondsToLocaleString } from './utils'; +import { ITrack } from '../types/spotifyplus/track'; /** @@ -305,3 +311,73 @@ export function truncateMediaList(mediaList: any, maxItems: number): string | un export function openWindowNewTab(url: string):void { window.open(url, "_blank"); } + + +/** + * Returns a list of track uris and names that will include the selected media + * item, and subsequent media items in the list up to the maximum specified. + * + * @param mediaList Source media list of `ITrack` items. + * @param mediaList Source media item that was selected. + * @param maxItems Maximum number of items to return (Spotify Web API can only play 50 tracks max). + * @param removeDuplicates True to remove duplicate items in the results list; otherwise, False to include duplicate items. + */ +export function getMediaListTrackUrisRemaining( + mediaList: Array, + mediaItem: ITrack, + maxItems: number | null = 50, + removeDuplicates: boolean | null = true, +): { uris: string[], names: string[] } { + + // build track uri list from media list. + const uris = new Array(); + const names = new Array(); + let count = 0; + let startFound = false; + + // process all items in the media list. + for (const item of (mediaList || [])) { + + // find the media item that was selected, so we can start adding + // all remaining items. + if (item.uri == mediaItem.uri) { + startFound = true; + } + + if (startFound) { + + // is item already in the list? + let isDuplicate = false; + if (removeDuplicates) { + for (const dupItem of (uris)) { + if (item.uri == dupItem) { + isDuplicate = true; + break; + } + } + } + + // if not a dupllicate, then add item to return list. + if (!isDuplicate) { + uris.push(item.uri); + names.push(item.name); + count += 1; + if (count >= (maxItems || 50)) { + break; + } + } + } + + } + + // trace. + if (debuglog.enabled) { + debuglog("getMediaListTrackUrisRemaining - track name(s) remaining:\n%s", + JSON.stringify(names, null, 2), + ); + } + + // return items to caller. + return { uris: uris, names: names }; + +} \ No newline at end of file From 851a8ec878e46ebc8e0d0eb3a1bfe7c135938198 Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Sat, 28 Dec 2024 11:22:03 -0600 Subject: [PATCH 17/17] [ 1.0.27 ] - Updated release notes. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 710b876..c725a3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Change are listed in reverse chronological order (newest to oldest). ###### [ 1.0.27 ] - 2024/12/28 + * This release requires the SpotifyPlus Integration v1.0.76+ release; please make sure you update the SpotifyPlus integration prior to updating this SpotifyPlus Card release. * Updated Devices section to remove device entries that wish to be hidden as specified by SpotifyPlus integration configuration "hide devices" option. * Added ability to play recently played tracks starting from selected track, which will now automatically add the following tracks (up to 50) to the player queue. Prior to this, only the selected track would play and then play would stop. Note that the 50 track limitation is a Spotify Web API limit. * Added ability to play player queue tracks starting from selected track, which will now automatically add the following tracks (up to 50) to the player queue. Prior to this, only the selected track would play and then play would stop. Note that the 50 track limitation is a Spotify Web API limit.