diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..be8a69f --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,17 @@ +module.exports = { + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features + sourceType: "module" // Allows for the use of imports + }, + extends: [ + "plugin:@typescript-eslint/recommended" + ], + rules: { + // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs + // e.g. "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-unused-vars": "warn", + //"@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-explicit-any": "error", + } +}; \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7c1ccc5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,32 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# correct GitHub "Languages" detection behavior. +# without this, GitHub reports the repository as an HTML application. +.github/** linguist-documentation +.vscode/** linguist-documentation +config/** linguist-documentation +scripts/** linguist-documentation +*.bat linguist-detectable=false +*.yaml linguist-detectable=false +*.txt linguist-detectable=false + +# handle files with specified extensions as described. +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain +*.jpg binary +*.png binary +*.gif binary +*.csproj text=auto merge=union +*.vbproj text=auto merge=union +*.fsproj text=auto merge=union +*.dbproj text=auto merge=union +*.sln -text merge=union diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ef790e3 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [thlucas1] +custom: ['https://www.buymeacoffee.com/thlucas1'] diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..0844e62 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,57 @@ +--- +name: "Bug report" +description: "Report a bug with the card" +labels: ["bug"] +body: +- type: markdown + attributes: + value: Before you open a new issue, search through the existing issues to see if others have had the same problem; also make sure you are running the latest version of Home Assistant. +- type: textarea + attributes: + label: "System Health details" + description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)" + validations: + required: true +- type: checkboxes + attributes: + label: Checklist + options: + - label: I am running the latest version of Home Assistant for my installation. + required: true + - label: I am running the latest version of the card. + required: true + - label: I have filled out the issue template to the best of my ability. + required: true + - label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue). + required: true + - label: This issue is not a duplicate issue of any [previous issues](https://github.com/thlucas1/spotifyplus_card/issues?q=is%3Aissue+label%3A%22Bug%22+). + required: true +- type: textarea + attributes: + label: "Describe the issue" + description: "A clear and concise description of what the issue is." + validations: + required: true +- type: textarea + attributes: + label: Reproduction Steps + description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed." + value: | + 1. + 2. + 3. + ... + validations: + required: true +- type: textarea + attributes: + label: "Debug / Console Logs" + description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue. You can also include browser console / developer tool logs here as well." + render: text + validations: + required: true + +- type: textarea + attributes: + label: "Diagnostics Dump (optional)" + description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ec4bb38 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..c1fa295 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,47 @@ +--- +name: "Feature request" +description: "Suggest an idea for this project" +labels: ["Feature+Request"] +body: +- type: markdown + attributes: + value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea. +- type: checkboxes + attributes: + label: Checklist + options: + - label: I have filled out the template to the best of my ability. + required: true + - label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request). + required: true + - label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/thlucas1/spotifyplus_card/issues?q=is%3Aissue+label%3A%22Feature+Request%22+). + required: true + +- type: textarea + attributes: + label: "Is your feature request related to a problem? Please describe." + description: "A clear and concise description of what the problem is." + placeholder: "I'm always frustrated when [...]" + validations: + required: true + +- type: textarea + attributes: + label: "Describe the solution you'd like" + description: "A clear and concise description of what you want to happen." + validations: + required: true + +- type: textarea + attributes: + label: "Describe alternatives you've considered" + description: "A clear and concise description of any alternative solutions or features you've considered." + validations: + required: true + +- type: textarea + attributes: + label: "Additional context" + description: "Add any other context or screenshots about the feature request here." + validations: + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4014ff5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" # Location of package manifests + schedule: + interval: "monthly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ba64bab --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,22 @@ +name: 'Build' + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + name: "Build Plugin" + runs-on: ubuntu-latest + steps: + - name: "Repository checkout" + uses: "actions/checkout@v4" + + - name: "NPM Build" + run: | + npm install + npm run buildgithub diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ece2dfb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: "Release" + +on: + release: + types: + - "published" + +permissions: {} + +jobs: + release: + name: "Prepare Release" + runs-on: "ubuntu-latest" + permissions: + contents: write + steps: + - name: "Repository checkout" + uses: "actions/checkout@v4" + + - name: "NPM Build Plugin" + run: | + cd "${{ github.workspace }}" + npm install + npm run buildgithub + + - name: "Upload Files to Release" + uses: softprops/action-gh-release@v0.1.15 + with: + files: "${{ github.workspace }}/dist/spotifyplus-card.js" + + # plugin file(s) will be placed in the following Home Assistant configuration directory + # location once it is installed via HACS: + # "/config/www/community/ha_spotifyplus_card/spotifyplus-card.js" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..2c62207 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,25 @@ +name: "Validate" + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + schedule: + - cron: "25 23 * * *" + workflow_dispatch: + +jobs: + validate-hacs: # https://github.com/hacs/action + name: "HACS Validation" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@main" + + - name: "Run HACS validation" + uses: "hacs/action@main" + with: + category: "plugin" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4224ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# artifacts +/build/ +/dist/ +.DS_Store + +# Visual Studio 2022 configuration. +/.vs/ +/obj/ +/bin/ + +# NPM +/node_modules/ + +# GIT support files to ignore. +/Git*.cmd + +# Local project files and folders to ignore. +/dist/Build.bat +/developer_notes.txt +/New_Release_Instructions.txt +/data/ diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..93f1e1c --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,7 @@ +module.exports = { + semi: true, + trailingComma: 'all', + singleQuote: true, + printWidth: 120, + tabWidth: 2, +}; \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..453d733 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,19 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "skipFiles": [ + "/**" + ], + "program": "${file}" + } + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..39b3dc6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +## Change Log + +All notable changes to this project are listed here. + +Change are listed in reverse chronological order (newest to oldest). + + + +###### [ 1.0.0 ] - 2024/08/25 + + * Version 1 initial release. + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f13aa69 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 - 2024 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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..93d45e9 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# SpotifyPlus Card + +[![GitHub Release][releases-shield]][releases] [![License][license-shield]](LICENSE) [![docs][docs-shield]][docs] [![hacs][hacs-shield]][hacs] + +![Project Maintenance][maintenance-shield] [![BuyMeCoffee][buymecoffee-shield]][buymecoffee] + +_Home Assistant UI card that supports features unique to the [SpotifyPlus](https://github.com/thlucas1/homeassistantcomponent_spotifyplus) custom integration._ +Extended support for the SpotifyPlus product for use in Home Assistant. + +## Features + +* Spotify Media player interface with customizable controls and information display. +* Spotify real-time search for all media types. +* 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). +* Favorite status / add / remove support for all media types. +* Card Configuration Editor User-Interface for changing options. + +and more! + +## How it Looks + +Here's a quick overview on what the card can look like. The card is highly customizable when it comes to the information displayed. Check out the [UI Dashboards wiki](https://github.com/thlucas1/spotifyplus_card/wiki/UI-Dashboards) page for more examples and YAML configuration. + +#### Media Player Control (Masonry Mode) +![masonry_player_track](./images/masonry_player_track.png) +![masonry_search_playlists](./images/masonry_search_playlists.png) + +#### Editor UI, General Options +![config_editor_general](./images/config_editor_general.png) + + +## HACS Installation Instructions (recommended) + +- On your Home Assistant sidebar menu, go to HACS > Frontend +- Click on the 3-dot overflow menu in the upper right, and select `custom repositories` item. +- Copy / paste `https://github.com/thlucas1/spotifyplus_card` in the Repository textbox and select `Lovelace` for the category entry. +- Click on `Add` to add the custom repository. +- You can then click on the `SpotifyPlus Card` repository entry (you may need to filter your list first to find the new entry). +- Click on `download` to start the download. It will install the card to your `/config/www/community/ha_spotifyplus_card` directory. +- Go back on your dashboard and click on the icon at the right top corner then on Edit dashboard. +- You can now click on Add card in the bottom right corner and search for "Custom: SpotifyPlus Card". + +## Manual Installation + +- using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). +- change directory to the `www` folder; if you don't have this directory, then create it. +- download the `spotifyplus-card.js` file from the [GitHub repository](https://github.com/thlucas1/spotifyplus_card) into your `/www` folder. +- on your dashboard click on the icon at the right top corner then on Edit dashboard. +- click again on that icon and then on Manage resources. +- click on Add resource. +- copy and paste this: `/local/spotifyplus-card.js?v=1`. +- click on JavaScript Module then Create. +- go back and refresh your page. +- you can now click on Add card in the bottom right corner and search for "Custom: SpotifyPlus Card". +- after any update of the file you will have to edit `/local/spotifyplus-card.js?v=1` and change the version (e.g. `v=1`) to any higher number (e.g. `v=1.2`). + + +## More Information + +Check out the following links for more information: + +- [Card Wiki Home](https://github.com/thlucas1/spotifyplus_card/wiki) +- [Card Features](https://github.com/thlucas1/spotifyplus_card/wiki/Card-Features) +- [Card Configuration](https://github.com/thlucas1/spotifyplus_card/wiki/Configuration-Options) +- [Card UI Examples](https://github.com/thlucas1/spotifyplus_card/wiki/UI-Dashboards) +- [SpotifyPlus Integration](https://github.com/thlucas1/homeassistantcomponent_spotifyplus) + + +## Reporting a Problem + +Submit a [Bug Report](https://github.com/thlucas1/spotifyplus_card/issues/new?assignees=&labels=Bug&projects=&template=bug.yml) to bring the issue to my attention. I receive a notification when a new issue is opened, and will do my best to address it in a prompt and professional manner. + +## Request a New Feature + +Do you have an idea for a new feature that could be added to the integration? Submit a [Feature Request](https://github.com/thlucas1/spotifyplus_card/issues/new?assignees=&labels=Feature%2BRequest&projects=&template=feature_request.yml) to get your idea into the queue. I receive a notification when a new request is opened, and will do my best to turn your idea into the latest and greatest feature. + +*** + +[releases-shield]: https://img.shields.io/github/release/thlucas1/spotifyplus_card.svg?style=for-the-badge +[releases]: https://github.com/thlucas1/spotifyplus_card/releases +[license-shield]: https://img.shields.io/github/license/thlucas1/spotifyplus_card.svg?style=for-the-badge +[docs]: https://github.com/thlucas1/spotifyplus_card/wiki +[docs-shield]: https://img.shields.io/badge/Docs-Wiki-blue.svg?style=for-the-badge +[hacs]: https://github.com/hacs/integration +[hacs-shield]: https://img.shields.io/badge/HACS-Default-41BDF5.svg?style=for-the-badge + +[maintenance-shield]: https://img.shields.io/badge/maintainer-Todd%20Lucas%20%40thlucas1-blue.svg?style=for-the-badge +[buymecoffee]: https://www.buymeacoffee.com/thlucas1 +[buymecoffee-shield]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge diff --git a/SpotifyPlusCard.njsproj b/SpotifyPlusCard.njsproj new file mode 100644 index 0000000..8291269 --- /dev/null +++ b/SpotifyPlusCard.njsproj @@ -0,0 +1,257 @@ + + + + Debug + 2.0 + {0777490A-BB48-44FB-941D-4758EA1310C6} + . + ProjectFiles + dist\spotifyplus-card.js + . + . + {3AF33F2E-1136-4D97-BBB7-1795711AC8B8};{349c5851-65df-11da-9384-00065b846f21};{9092AA53-FB77-4645-B42D-1CCCA6BD08BD} + true + 17.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + True + Jest + True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + False + True + 0 + / + http://localhost:48022/ + False + True + http://localhost:1337 + False + + + + + + + CurrentPage + True + False + False + False + + + + + + + + + False + False + + + + + \ No newline at end of file diff --git a/SpotifyPlusCard.njsproj.user b/SpotifyPlusCard.njsproj.user new file mode 100644 index 0000000..db86229 --- /dev/null +++ b/SpotifyPlusCard.njsproj.user @@ -0,0 +1,6 @@ + + + + Debug|Any CPU + + \ No newline at end of file diff --git a/SpotifyPlusCard.sln b/SpotifyPlusCard.sln new file mode 100644 index 0000000..3e4d2c0 --- /dev/null +++ b/SpotifyPlusCard.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.7.34031.279 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9092AA53-FB77-4645-B42D-1CCCA6BD08BD}") = "SpotifyPlusCard", "SpotifyPlusCard.njsproj", "{0777490A-BB48-44FB-941D-4758EA1310C6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {0777490A-BB48-44FB-941D-4758EA1310C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0777490A-BB48-44FB-941D-4758EA1310C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0777490A-BB48-44FB-941D-4758EA1310C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0777490A-BB48-44FB-941D-4758EA1310C6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {565C40C8-67DC-409B-AA17-959A4D8DE3BF} + EndGlobalSection +EndGlobal diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..f064d1e --- /dev/null +++ b/hacs.json @@ -0,0 +1,6 @@ +{ + "name": "SpotifyPlus Card", + "filename": "spotifyplus-card.js", + "hide_default_branch": true, + "homeassistant": "2024.8.0", +} diff --git a/images/config_cardpicker.png b/images/config_cardpicker.png new file mode 100644 index 0000000..82b7fd2 Binary files /dev/null and b/images/config_cardpicker.png differ diff --git a/images/config_editor_albums.png b/images/config_editor_albums.png new file mode 100644 index 0000000..564014d Binary files /dev/null and b/images/config_editor_albums.png differ diff --git a/images/config_editor_artists.png b/images/config_editor_artists.png new file mode 100644 index 0000000..bd5472d Binary files /dev/null and b/images/config_editor_artists.png differ diff --git a/images/config_editor_audiobooks.png b/images/config_editor_audiobooks.png new file mode 100644 index 0000000..ec73fb4 Binary files /dev/null and b/images/config_editor_audiobooks.png differ diff --git a/images/config_editor_devices.png b/images/config_editor_devices.png new file mode 100644 index 0000000..b39172c Binary files /dev/null and b/images/config_editor_devices.png differ diff --git a/images/config_editor_episodes.png b/images/config_editor_episodes.png new file mode 100644 index 0000000..7afd8d9 Binary files /dev/null and b/images/config_editor_episodes.png differ diff --git a/images/config_editor_general.png b/images/config_editor_general.png new file mode 100644 index 0000000..1ffa8b2 Binary files /dev/null and b/images/config_editor_general.png differ diff --git a/images/config_editor_player_controls.png b/images/config_editor_player_controls.png new file mode 100644 index 0000000..40e4f90 Binary files /dev/null and b/images/config_editor_player_controls.png differ diff --git a/images/config_editor_player_header.png b/images/config_editor_player_header.png new file mode 100644 index 0000000..3e4363b Binary files /dev/null and b/images/config_editor_player_header.png differ diff --git a/images/config_editor_player_volume.png b/images/config_editor_player_volume.png new file mode 100644 index 0000000..871460c Binary files /dev/null and b/images/config_editor_player_volume.png differ diff --git a/images/config_editor_playlists.png b/images/config_editor_playlists.png new file mode 100644 index 0000000..4bccc38 Binary files /dev/null and b/images/config_editor_playlists.png differ diff --git a/images/config_editor_presets.png b/images/config_editor_presets.png new file mode 100644 index 0000000..22c7985 Binary files /dev/null and b/images/config_editor_presets.png differ diff --git a/images/config_editor_recents.png b/images/config_editor_recents.png new file mode 100644 index 0000000..fb1d8de Binary files /dev/null and b/images/config_editor_recents.png differ diff --git a/images/config_editor_search.png b/images/config_editor_search.png new file mode 100644 index 0000000..14706b0 Binary files /dev/null and b/images/config_editor_search.png differ diff --git a/images/config_editor_shows.png b/images/config_editor_shows.png new file mode 100644 index 0000000..5e66f88 Binary files /dev/null and b/images/config_editor_shows.png differ diff --git a/images/config_editor_tracks.png b/images/config_editor_tracks.png new file mode 100644 index 0000000..e64a778 Binary files /dev/null and b/images/config_editor_tracks.png differ diff --git a/images/masonry_album_favorites.png b/images/masonry_album_favorites.png new file mode 100644 index 0000000..6615789 Binary files /dev/null and b/images/masonry_album_favorites.png differ diff --git a/images/masonry_album_favorites_actions.png b/images/masonry_album_favorites_actions.png new file mode 100644 index 0000000..943ca3c Binary files /dev/null and b/images/masonry_album_favorites_actions.png differ diff --git a/images/masonry_artist_favorites.png b/images/masonry_artist_favorites.png new file mode 100644 index 0000000..95f87e8 Binary files /dev/null and b/images/masonry_artist_favorites.png differ diff --git a/images/masonry_artist_favorites_actions.png b/images/masonry_artist_favorites_actions.png new file mode 100644 index 0000000..e9d1cb2 Binary files /dev/null and b/images/masonry_artist_favorites_actions.png differ diff --git a/images/masonry_audiobook_favorites.png b/images/masonry_audiobook_favorites.png new file mode 100644 index 0000000..6eaee61 Binary files /dev/null and b/images/masonry_audiobook_favorites.png differ diff --git a/images/masonry_audiobook_favorites_actions.png b/images/masonry_audiobook_favorites_actions.png new file mode 100644 index 0000000..1f871c8 Binary files /dev/null and b/images/masonry_audiobook_favorites_actions.png differ diff --git a/images/masonry_devices.png b/images/masonry_devices.png new file mode 100644 index 0000000..51d2cce Binary files /dev/null and b/images/masonry_devices.png differ diff --git a/images/masonry_devices_actions.png b/images/masonry_devices_actions.png new file mode 100644 index 0000000..03c6c6b Binary files /dev/null and b/images/masonry_devices_actions.png differ diff --git a/images/masonry_episode_favorites.png b/images/masonry_episode_favorites.png new file mode 100644 index 0000000..27dd6ad Binary files /dev/null and b/images/masonry_episode_favorites.png differ diff --git a/images/masonry_episode_favorites_actions.png b/images/masonry_episode_favorites_actions.png new file mode 100644 index 0000000..de793fc Binary files /dev/null and b/images/masonry_episode_favorites_actions.png differ diff --git a/images/masonry_player_audiobook.png b/images/masonry_player_audiobook.png new file mode 100644 index 0000000..3c4c866 Binary files /dev/null and b/images/masonry_player_audiobook.png differ diff --git a/images/masonry_player_audiobook_actions.png b/images/masonry_player_audiobook_actions.png new file mode 100644 index 0000000..4c7af88 Binary files /dev/null and b/images/masonry_player_audiobook_actions.png differ diff --git a/images/masonry_player_show.png b/images/masonry_player_show.png new file mode 100644 index 0000000..6caad3b Binary files /dev/null and b/images/masonry_player_show.png differ diff --git a/images/masonry_player_show_actions.png b/images/masonry_player_show_actions.png new file mode 100644 index 0000000..05abf23 Binary files /dev/null and b/images/masonry_player_show_actions.png differ diff --git a/images/masonry_player_track.png b/images/masonry_player_track.png new file mode 100644 index 0000000..97243a0 Binary files /dev/null and b/images/masonry_player_track.png differ diff --git a/images/masonry_player_track_actions.png b/images/masonry_player_track_actions.png new file mode 100644 index 0000000..9676896 Binary files /dev/null and b/images/masonry_player_track_actions.png differ diff --git a/images/masonry_playlist_favorites.png b/images/masonry_playlist_favorites.png new file mode 100644 index 0000000..2cec75f Binary files /dev/null and b/images/masonry_playlist_favorites.png differ diff --git a/images/masonry_playlist_favorites_actions.png b/images/masonry_playlist_favorites_actions.png new file mode 100644 index 0000000..77d0e24 Binary files /dev/null and b/images/masonry_playlist_favorites_actions.png differ diff --git a/images/masonry_recents.png b/images/masonry_recents.png new file mode 100644 index 0000000..66b8dfa Binary files /dev/null and b/images/masonry_recents.png differ diff --git a/images/masonry_recents_actions.png b/images/masonry_recents_actions.png new file mode 100644 index 0000000..cee0995 Binary files /dev/null and b/images/masonry_recents_actions.png differ diff --git a/images/masonry_search_albums.png b/images/masonry_search_albums.png new file mode 100644 index 0000000..ff4e08a Binary files /dev/null and b/images/masonry_search_albums.png differ diff --git a/images/masonry_search_albums_actions.png b/images/masonry_search_albums_actions.png new file mode 100644 index 0000000..0c9ad32 Binary files /dev/null and b/images/masonry_search_albums_actions.png differ diff --git a/images/masonry_search_artists.png b/images/masonry_search_artists.png new file mode 100644 index 0000000..ba0e236 Binary files /dev/null and b/images/masonry_search_artists.png differ diff --git a/images/masonry_search_artists_actions.png b/images/masonry_search_artists_actions.png new file mode 100644 index 0000000..25a3d08 Binary files /dev/null and b/images/masonry_search_artists_actions.png differ diff --git a/images/masonry_search_audiobooks.png b/images/masonry_search_audiobooks.png new file mode 100644 index 0000000..14eda8d Binary files /dev/null and b/images/masonry_search_audiobooks.png differ diff --git a/images/masonry_search_audiobooks_actions.png b/images/masonry_search_audiobooks_actions.png new file mode 100644 index 0000000..9534d8c Binary files /dev/null and b/images/masonry_search_audiobooks_actions.png differ diff --git a/images/masonry_search_episodes.png b/images/masonry_search_episodes.png new file mode 100644 index 0000000..60324bf Binary files /dev/null and b/images/masonry_search_episodes.png differ diff --git a/images/masonry_search_episodes_actions.png b/images/masonry_search_episodes_actions.png new file mode 100644 index 0000000..ca056ea Binary files /dev/null and b/images/masonry_search_episodes_actions.png differ diff --git a/images/masonry_search_playlists.png b/images/masonry_search_playlists.png new file mode 100644 index 0000000..545ded7 Binary files /dev/null and b/images/masonry_search_playlists.png differ diff --git a/images/masonry_search_playlists_actions.png b/images/masonry_search_playlists_actions.png new file mode 100644 index 0000000..2479823 Binary files /dev/null and b/images/masonry_search_playlists_actions.png differ diff --git a/images/masonry_search_shows.png b/images/masonry_search_shows.png new file mode 100644 index 0000000..7974e16 Binary files /dev/null and b/images/masonry_search_shows.png differ diff --git a/images/masonry_search_shows_actions.png b/images/masonry_search_shows_actions.png new file mode 100644 index 0000000..83f8bc1 Binary files /dev/null and b/images/masonry_search_shows_actions.png differ diff --git a/images/masonry_search_tracks.png b/images/masonry_search_tracks.png new file mode 100644 index 0000000..fba33dc Binary files /dev/null and b/images/masonry_search_tracks.png differ diff --git a/images/masonry_search_tracks_actions.png b/images/masonry_search_tracks_actions.png new file mode 100644 index 0000000..86a712d Binary files /dev/null and b/images/masonry_search_tracks_actions.png differ diff --git a/images/masonry_show_favorites.png b/images/masonry_show_favorites.png new file mode 100644 index 0000000..49478f4 Binary files /dev/null and b/images/masonry_show_favorites.png differ diff --git a/images/masonry_show_favorites_actions.png b/images/masonry_show_favorites_actions.png new file mode 100644 index 0000000..6bbdfff Binary files /dev/null and b/images/masonry_show_favorites_actions.png differ diff --git a/images/masonry_track_favorites.png b/images/masonry_track_favorites.png new file mode 100644 index 0000000..b86d14e Binary files /dev/null and b/images/masonry_track_favorites.png differ diff --git a/images/masonry_track_favorites_actions.png b/images/masonry_track_favorites_actions.png new file mode 100644 index 0000000..75a1183 Binary files /dev/null and b/images/masonry_track_favorites_actions.png differ diff --git a/images/masonry_userpresets.png b/images/masonry_userpresets.png new file mode 100644 index 0000000..e946d85 Binary files /dev/null and b/images/masonry_userpresets.png differ diff --git a/images/masonry_userpresets_actions.png b/images/masonry_userpresets_actions.png new file mode 100644 index 0000000..6856a9c Binary files /dev/null and b/images/masonry_userpresets_actions.png differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..38ec790 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,11436 @@ +{ + "name": "spotifyplus-card", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spotifyplus-card", + "license": "MIT", + "dependencies": { + "@mdi/js": "^7.4.47", + "@vibrant/color": "^3.2.1-alpha.1", + "@vibrant/core": "^3.2.1-alpha.1", + "@vibrant/generator-default": "^3.2.1-alpha.1", + "@vibrant/image": "^3.2.1-alpha.1", + "@vibrant/image-browser": "^3.2.1-alpha.1", + "@vibrant/image-node": "^3.2.1-alpha.1", + "@vibrant/quantizer-mmcq": "^3.2.1-alpha.1", + "custom-card-helpers": "^1.9.0", + "debug": "^4.3.7", + "lit": "^2.8.0", + "node-vibrant": "^3.2.1-alpha.1", + "url": "^0.11.4" + }, + "devDependencies": { + "@babel/core": "^7.24.4", + "@material/web": "^2.1.0", + "@rollup/plugin-babel": "^6.0.4", + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.3.0", + "@rollup/plugin-typescript": "^12.1.0", + "@types/debug": "^4.1.12", + "@types/jest": "27.4.1", + "@types/query-selector-shadow-dom": "^1.0.4", + "@typescript-eslint/eslint-plugin": "^7.6.0", + "@typescript-eslint/parser": "^7.7.0", + "eslint": "^8.57.1", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.30.0", + "eslint-plugin-prettier": "^5.1.3", + "jest": "27.5.1", + "prettier": "^3.2.5", + "rimraf": "^6.0.1", + "rollup": "^2.79.2", + "rollup-plugin-serve": "^2.0.3", + "rollup-plugin-sourcemaps": "^0.6.3", + "rollup-plugin-terser": "^7.0.2", + "rollup-plugin-typescript2": "^0.36.0", + "source-map-loader": "^5.0.0", + "ts-loader": "^9.5.1", + "tsconfig-paths-webpack-plugin": "^4.1.0", + "typescript": "^4.9.5", + "webpack": "^5.94.0", + "webpack-bundle-analyzer": "^4.10.2", + "webpack-cli": "^5.1.4", + "worker-loader": "^3.0.8" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", + "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.24.7", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz", + "integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", + "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-module-transforms": "^7.25.2", + "@babel/helpers": "^7.25.0", + "@babel/parser": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.2", + "@babel/types": "^7.25.2", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.25.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.5.tgz", + "integrity": "sha512-abd43wyLfbWoxC6ahM8xTkqLpGB2iWBVyuKC9/srhFunCd1SDNrV1s72bBpK4hLj8KLzHBBcOblvLQZBNw9r3w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.25.4", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz", + "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.25.2", + "@babel/helper-validator-option": "^7.24.8", + "browserslist": "^4.23.1", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", + "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz", + "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-simple-access": "^7.24.7", + "@babel/helper-validator-identifier": "^7.24.7", + "@babel/traverse": "^7.25.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz", + "integrity": "sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz", + "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", + "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz", + "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.0.tgz", + "integrity": "sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", + "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.24.7", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.4.tgz", + "integrity": "sha512-nq+eWrOgdtu3jG5Os4TQP3x3cLA8hR8TvJNjD8vnPa20WGycimcparWnLK4jJhElTK6SDyuJo1weMKO/5LpmLA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.25.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.7.tgz", + "integrity": "sha512-hbX+lKKeUMGihnK8nvKqmXBInriT3GVjzXKFriV3YC6APGxMbP8RZNFwy91+hocLXq90Mta+HshoB31802bb8A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.4.tgz", + "integrity": "sha512-uMOCoHVU52BsSWxPOMVv5qKRdeSlPuImUCB2dlPuBSU+W2/ROE7/Zg8F2Kepbk+8yBa68LlRKxO+xgEVWorsDg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz", + "integrity": "sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", + "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/parser": "^7.25.0", + "@babel/types": "^7.25.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.4.tgz", + "integrity": "sha512-VJ4XsrD+nOvlXyLzmLzUs/0qjFS4sK30te5yEFlvbbUNEgKaVb2BHZUpAL+ttLPQAHNrsI3zZisbfha5Cvr8vg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.4", + "@babel/parser": "^7.25.4", + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.4", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.4.tgz", + "integrity": "sha512-zQ1ijeeCXVEh+aNL0RlmkPkG8HUiDcU2pzQQFjtbntgAczRASFzj4H+6+bV+dy1ntKR14I/DypeuRG1uma98iQ==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "engines": { + "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", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", + "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/console/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/console/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/console/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/console/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@jest/console/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/core": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.5.1.tgz", + "integrity": "sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==", + "dev": true, + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/reporters": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^27.5.1", + "jest-config": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-resolve-dependencies": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "jest-watcher": "^27.5.1", + "micromatch": "^4.0.4", + "rimraf": "^3.0.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/core/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/core/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@jest/core/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/core/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/core/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@jest/core/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/core/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/core/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@jest/core/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/core/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/environment": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", + "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", + "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "@sinonjs/fake-timers": "^8.0.1", + "@types/node": "*", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz", + "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/types": "^27.5.1", + "expect": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-27.5.1.tgz", + "integrity": "sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.2", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^5.1.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-haste-map": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "slash": "^3.0.0", + "source-map": "^0.6.0", + "string-length": "^4.0.1", + "terminal-link": "^2.0.0", + "v8-to-istanbul": "^8.1.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@jest/reporters/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/reporters/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/reporters/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@jest/reporters/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/source-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", + "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9", + "source-map": "^0.6.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", + "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", + "dev": true, + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz", + "integrity": "sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ==", + "dev": true, + "dependencies": { + "@jest/test-result": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-runtime": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", + "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.1.0", + "@jest/types": "^27.5.1", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-util": "^27.5.1", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/transform/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/transform/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/transform/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@jest/transform/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, + "node_modules/@jest/transform/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/transform/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/types/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/types/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/types/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/types/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@jest/types/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/types/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jimp/bmp": { + "version": "0.16.13", + "resolved": "https://registry.npmjs.org/@jimp/bmp/-/bmp-0.16.13.tgz", + "integrity": "sha512-9edAxu7N2FX7vzkdl5Jo1BbACfycUtBQX+XBMcHA2bk62P8R0otgkHg798frgAk/WxQIzwxqOH6wMiCwrlAzdQ==", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.16.13", + "bmp-js": "^0.1.0" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/core": { + "version": "0.16.13", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-0.16.13.tgz", + "integrity": "sha512-qXpA1tzTnlkTku9yqtuRtS/wVntvE6f3m3GNxdTdtmc+O+Wcg9Xo2ABPMh7Nc0AHbMKzwvwgB2JnjZmlmJEObg==", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.16.13", + "any-base": "^1.1.0", + "buffer": "^5.2.0", + "exif-parser": "^0.1.12", + "file-type": "^16.5.4", + "load-bmfont": "^1.3.1", + "mkdirp": "^0.5.1", + "phin": "^2.9.1", + "pixelmatch": "^4.0.2", + "tinycolor2": "^1.4.1" + } + }, + "node_modules/@jimp/custom": { + "version": "0.16.13", + "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.16.13.tgz", + "integrity": "sha512-LTATglVUPGkPf15zX1wTMlZ0+AU7cGEGF6ekVF1crA8eHUWsGjrYTB+Ht4E3HTrCok8weQG+K01rJndCp/l4XA==", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/core": "^0.16.13" + } + }, + "node_modules/@jimp/gif": { + "version": "0.16.13", + "resolved": "https://registry.npmjs.org/@jimp/gif/-/gif-0.16.13.tgz", + "integrity": "sha512-yFAMZGv3o+YcjXilMWWwS/bv1iSqykFahFMSO169uVMtfQVfa90kt4/kDwrXNR6Q9i6VHpFiGZMlF2UnHClBvg==", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.16.13", + "gifwrap": "^0.9.2", + "omggif": "^1.0.9" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/jpeg": { + "version": "0.16.13", + "resolved": "https://registry.npmjs.org/@jimp/jpeg/-/jpeg-0.16.13.tgz", + "integrity": "sha512-BJHlDxzTlCqP2ThqP8J0eDrbBfod7npWCbJAcfkKqdQuFk0zBPaZ6KKaQKyKxmWJ87Z6ohANZoMKEbtvrwz1AA==", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.16.13", + "jpeg-js": "^0.4.2" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-resize": { + "version": "0.16.13", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.16.13.tgz", + "integrity": "sha512-qoqtN8LDknm3fJm9nuPygJv30O3vGhSBD2TxrsCnhtOsxKAqVPJtFVdGd/qVuZ8nqQANQmTlfqTiK9mVWQ7MiQ==", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.16.13" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/png": { + "version": "0.16.13", + "resolved": "https://registry.npmjs.org/@jimp/png/-/png-0.16.13.tgz", + "integrity": "sha512-8cGqINvbWJf1G0Her9zbq9I80roEX0A+U45xFby3tDWfzn+Zz8XKDF1Nv9VUwVx0N3zpcG1RPs9hfheG4Cq2kg==", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.16.13", + "pngjs": "^3.3.3" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/tiff": { + "version": "0.16.13", + "resolved": "https://registry.npmjs.org/@jimp/tiff/-/tiff-0.16.13.tgz", + "integrity": "sha512-oJY8d9u95SwW00VPHuCNxPap6Q1+E/xM5QThb9Hu+P6EGuu6lIeLaNBMmFZyblwFbwrH+WBOZlvIzDhi4Dm/6Q==", + "dependencies": { + "@babel/runtime": "^7.7.2", + "utif": "^2.0.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/types": { + "version": "0.16.13", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-0.16.13.tgz", + "integrity": "sha512-mC0yVNUobFDjoYLg4hoUwzMKgNlxynzwt3cDXzumGvRJ7Kb8qQGOWJQjQFo5OxmGExqzPphkirdbBF88RVLBCg==", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/bmp": "^0.16.13", + "@jimp/gif": "^0.16.13", + "@jimp/jpeg": "^0.16.13", + "@jimp/png": "^0.16.13", + "@jimp/tiff": "^0.16.13", + "timm": "^1.6.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/utils": { + "version": "0.16.13", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-0.16.13.tgz", + "integrity": "sha512-VyCpkZzFTHXtKgVO35iKN0sYR10psGpV6SkcSeV4oF7eSYlR8Bl6aQLCzVeFjvESF7mxTmIiI3/XrMobVrtxDA==", + "dependencies": { + "@babel/runtime": "^7.7.2", + "regenerator-runtime": "^0.13.3" + } + }, + "node_modules/@jimp/utils/node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.1.tgz", + "integrity": "sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ==" + }, + "node_modules/@lit/reactive-element": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz", + "integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.0.0" + } + }, + "node_modules/@material/web": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@material/web/-/web-2.1.0.tgz", + "integrity": "sha512-xzRB6GSspfhscWJvu7Ct8T+2Ybiasnk8OCe3PlWDE/LDEPZFyJJE7K0D6cANscJKQ/GIGIeLVkq5FEQRi+KUew==", + "dev": true, + "workspaces": [ + "catalog" + ], + "dependencies": { + "lit": "^2.7.4 || ^3.0.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@mdi/js": { + "version": "7.4.47", + "resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz", + "integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", + "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.25", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", + "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==", + "dev": true + }, + "node_modules/@rollup/plugin-babel": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.0.4.tgz", + "integrity": "sha512-YF7Y52kFdFT/xVSuVdjkV5ZdX/3YtmX0QulG+x0taQOtJdHYzVU61aSSkAgVJ7NOv6qPkIYiJSgSWWN/DM5sGw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@rollup/pluginutils": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "25.0.8", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.8.tgz", + "integrity": "sha512-ZEZWTK5n6Qde0to4vS9Mr5x/0UZoqCxPVR9KRUjU4kA2sO7GEUn1fop0DAwpO6z0Nw/kJON9bDmSxdWxO/TT1A==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "glob": "^8.0.3", + "is-reference": "1.2.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.0.tgz", + "integrity": "sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-typescript": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.1.0.tgz", + "integrity": "sha512-Kzs8KGJofe7cfTRODsnG1jNGxSvU8gVoNNd7Z/QaY25AYwe2LSSUpx/kPxqF38NYkpR8de3m51r9uwJpDlz6dg==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0||^4.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.0.tgz", + "integrity": "sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true + }, + "node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", + "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "27.4.1", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.4.1.tgz", + "integrity": "sha512-23iPJADSmicDVrWk+HT58LMJtzLAnB2AgIzplQuq/bSrGaxCrlvRFjGbXmamnnk/mAmCdLStiGqggu28ocUyiw==", + "dev": true, + "dependencies": { + "jest-matcher-utils": "^27.0.0", + "pretty-format": "^27.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true + }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.5.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", + "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", + "dev": true, + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/prettier": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", + "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", + "dev": true + }, + "node_modules/@types/query-selector-shadow-dom": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.4.tgz", + "integrity": "sha512-8jfGPD0wCMCdyBvrvOrWVn8bHL1UEjkPVJKsqNZpEXp+a7mIIvvGpJMd6n6dlNl7IkG2ryxIVqFI516RPE0uhQ==", + "dev": true + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + }, + "node_modules/@types/yargs": { + "version": "16.0.9", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.9.tgz", + "integrity": "sha512-tHhzvkFXZQeTECenFoRljLBYPZJ7jAVxqqtEI0qTLOmuultnFp4I9yKE17vTuhf7BkhCu7I4XuemPgikDVuYqA==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, + "node_modules/@vibrant/color": { + "version": "3.2.1-alpha.1", + "resolved": "https://registry.npmjs.org/@vibrant/color/-/color-3.2.1-alpha.1.tgz", + "integrity": "sha512-cvm+jAPwao2NerTr3d1JttYyLhp3eD/AQBeevxF7KT6HctToWZCwr2AeTr003/wKgbjzdOV1qySnbyOeu+R+Jw==" + }, + "node_modules/@vibrant/core": { + "version": "3.2.1-alpha.1", + "resolved": "https://registry.npmjs.org/@vibrant/core/-/core-3.2.1-alpha.1.tgz", + "integrity": "sha512-X9Oa9WfPEQnZ6L+5dLRlh+IlsxJkYTw9b/g3stFKoNXbVRKCeXHmH48l7jIBBOg3VcXOGUdsYBqsTwPNkIveaA==", + "dependencies": { + "@vibrant/color": "^3.2.1-alpha.1", + "@vibrant/generator": "^3.2.1-alpha.1", + "@vibrant/image": "^3.2.1-alpha.1", + "@vibrant/quantizer": "^3.2.1-alpha.1", + "@vibrant/types": "^3.2.1-alpha.1", + "@vibrant/worker": "^3.2.1-alpha.1" + } + }, + "node_modules/@vibrant/generator": { + "version": "3.2.1-alpha.1", + "resolved": "https://registry.npmjs.org/@vibrant/generator/-/generator-3.2.1-alpha.1.tgz", + "integrity": "sha512-luS5YvMhwMqG01YTj1dJ+cmkuIw1VCByOR6zIaCOwQqI/mcOs88JBWcA1r2TywJTOPlVpjfnDvAlyaKBKh4dMA==", + "dependencies": { + "@vibrant/color": "^3.2.1-alpha.1", + "@vibrant/types": "^3.2.1-alpha.1" + } + }, + "node_modules/@vibrant/generator-default": { + "version": "3.2.1-alpha.1", + "resolved": "https://registry.npmjs.org/@vibrant/generator-default/-/generator-default-3.2.1-alpha.1.tgz", + "integrity": "sha512-BWnQhDaz92UhyHnpdAzKXHQecY+jvyMXtzjKYbveFxThm6+HVoLjwONlbck7oyOpFzV2OM7V11XuR85BxaHvjw==", + "dependencies": { + "@vibrant/color": "^3.2.1-alpha.1", + "@vibrant/generator": "^3.2.1-alpha.1" + } + }, + "node_modules/@vibrant/image": { + "version": "3.2.1-alpha.1", + "resolved": "https://registry.npmjs.org/@vibrant/image/-/image-3.2.1-alpha.1.tgz", + "integrity": "sha512-4aF5k79QfyhZOqRovJpbnIjWfe3uuWhY8voqVdd4/qgu4o70/AwVlM+pYmCaJVzI45VWNWWHYA5QlYuKsXnBqQ==", + "dependencies": { + "@vibrant/color": "^3.2.1-alpha.1", + "@vibrant/types": "^3.2.1-alpha.1" + } + }, + "node_modules/@vibrant/image-browser": { + "version": "3.2.1-alpha.1", + "resolved": "https://registry.npmjs.org/@vibrant/image-browser/-/image-browser-3.2.1-alpha.1.tgz", + "integrity": "sha512-6xWvQfB20sE6YtCWylgEAHuee3iD8h3aFIDbCS2yj7jIelKcYTrrp5jg2d2BhOOB6pC5JzF+QfpCrm0DmAIlgQ==", + "dependencies": { + "@vibrant/image": "^3.2.1-alpha.1" + } + }, + "node_modules/@vibrant/image-node": { + "version": "3.2.1-alpha.1", + "resolved": "https://registry.npmjs.org/@vibrant/image-node/-/image-node-3.2.1-alpha.1.tgz", + "integrity": "sha512-/Io/Rpo4EkO6AhaXdcxUXkbOFhSFtjm0LSAM4c0AyGA5EbC8PyZqjk8b11bQAEMCaYaweFQfTdGD7oVbXe21CQ==", + "dependencies": { + "@jimp/custom": "^0.16.1", + "@jimp/plugin-resize": "^0.16.1", + "@jimp/types": "^0.16.1", + "@vibrant/image": "^3.2.1-alpha.1" + } + }, + "node_modules/@vibrant/quantizer": { + "version": "3.2.1-alpha.1", + "resolved": "https://registry.npmjs.org/@vibrant/quantizer/-/quantizer-3.2.1-alpha.1.tgz", + "integrity": "sha512-iHnPx/+n4iLtYLm1GClSfyg2fFbMatFG0ipCyp9M6tXNIPAg+pSvUJSGBnVnH7Nl/bR8Gkkj1h0pJ4RsKcdIrQ==", + "dependencies": { + "@vibrant/color": "^3.2.1-alpha.1", + "@vibrant/image": "^3.2.1-alpha.1", + "@vibrant/types": "^3.2.1-alpha.1" + } + }, + "node_modules/@vibrant/quantizer-mmcq": { + "version": "3.2.1-alpha.1", + "resolved": "https://registry.npmjs.org/@vibrant/quantizer-mmcq/-/quantizer-mmcq-3.2.1-alpha.1.tgz", + "integrity": "sha512-Wuk9PTZtxr8qsWTcgP6lcrrmrq36syVwxf+BUxdgQYntBcQ053SaN34lVGOJ0WPdK5vABoxbYljhceCgiILtZw==", + "dependencies": { + "@vibrant/color": "^3.2.1-alpha.1", + "@vibrant/image": "^3.2.1-alpha.1", + "@vibrant/quantizer": "^3.2.1-alpha.1" + } + }, + "node_modules/@vibrant/types": { + "version": "3.2.1-alpha.1", + "resolved": "https://registry.npmjs.org/@vibrant/types/-/types-3.2.1-alpha.1.tgz", + "integrity": "sha512-ts9u7nsrENoYI5s0MmPOeY5kCLFKvQndKVDOPFCbTA0z493uhDp8mpiQhjFYTf3kPbS04z9zbHLE2luFC7x4KQ==" + }, + "node_modules/@vibrant/worker": { + "version": "3.2.1-alpha.1", + "resolved": "https://registry.npmjs.org/@vibrant/worker/-/worker-3.2.1-alpha.1.tgz", + "integrity": "sha512-mtSlBdHkFNr4FOnMtqtHJxy9z5AsUcZzGlpiHzvWOoaoN9lNTDPwxOBd0q4VTYWuGPrIm6Fuq5m7aRbLv7KqiQ==", + "dependencies": { + "@vibrant/types": "^3.2.1-alpha.1" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", + "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "dev": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", + "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.12.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", + "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-opt": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1", + "@webassemblyjs/wast-printer": "1.12.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", + "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", + "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", + "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", + "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", + "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", + "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", + "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true + }, + "node_modules/acorn": { + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", + "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", + "dev": true, + "dependencies": { + "acorn": "^7.1.1", + "acorn-walk": "^7.1.1" + } + }, + "node_modules/acorn-globals/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "dev": true, + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", + "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true, + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", + "integrity": "sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==", + "dev": true, + "dependencies": { + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-jest/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/babel-jest/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/babel-jest/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/babel-jest/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/babel-jest/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-jest/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz", + "integrity": "sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ==", + "dev": true, + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.0.0", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", + "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", + "dev": true, + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz", + "integrity": "sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag==", + "dev": true, + "dependencies": { + "babel-plugin-jest-hoist": "^27.5.1", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==" + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-process-hrtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", + "dev": true + }, + "node_modules/browserslist": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-0.0.1.tgz", + "integrity": "sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001653", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001653.tgz", + "integrity": "sha512-XGWQVB8wFQ2+9NZwZ10GxTYC5hk0Fa+q8cSkr0tgvMhYhMHP/QC+WTgrePMDBWiWc/pV+1ik82Al20XOK25Gcw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/centra": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/centra/-/centra-2.7.0.tgz", + "integrity": "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg==", + "dependencies": { + "follow-redirects": "^1.15.6" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz", + "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==", + "dev": true + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/confusing-browser-globals": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", + "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssom": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", + "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==", + "dev": true + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "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", + "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", + "dev": true, + "dependencies": { + "abab": "^2.0.3", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "dev": true + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true + }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "dev": true, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", + "dev": true + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", + "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" + }, + "node_modules/domexception": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", + "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "dependencies": { + "webidl-conversions": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/domexception/node_modules/webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz", + "integrity": "sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==", + "dev": true + }, + "node_modules/emittery": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz", + "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/envinfo": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.13.0.tgz", + "integrity": "sha512-cvcaMr7KqXVh4nyzGTVqTum+gAiL265x5jUWQIDLq//zOGbW+gSW/C+OWLleY/rs9Qole6AZLMXPbtIFQbqu+Q==", + "dev": true, + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", + "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.3", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.13", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.15" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", + "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "dev": true + }, + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-airbnb-base": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz", + "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==", + "dev": true, + "dependencies": { + "confusing-browser-globals": "^1.0.10", + "object.assign": "^4.1.2", + "object.entries": "^1.1.5", + "semver": "^6.3.0" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "peerDependencies": { + "eslint": "^7.32.0 || ^8.2.0", + "eslint-plugin-import": "^2.25.2" + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", + "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", + "dev": true, + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.30.0.tgz", + "integrity": "sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==", + "dev": true, + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.8", + "array.prototype.findlastindex": "^1.2.5", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.9.0", + "hasown": "^2.0.2", + "is-core-module": "^2.15.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.0", + "semver": "^6.3.1", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + } + }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", + "integrity": "sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.9.1" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": "*", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/eslint/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exif-parser": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", + "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==" + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/file-type": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", + "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-cache-dir/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flat-cache/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/flat-cache/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/flat-cache/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/flat-cache/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gifwrap": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.9.4.tgz", + "integrity": "sha512-MDMwbhASQuVeD4JKd1fKgNgCRL3fGqMM4WaqpNhWO0JiMOAjbQdumbs4BbBZEy9/M00EHEjKN3HieVhCUlwjeQ==", + "dependencies": { + "image-q": "^4.0.0", + "omggif": "^1.0.10" + } + }, + "node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "node_modules/glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "dependencies": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==" + }, + "node_modules/html-encoding-sniffer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", + "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^1.0.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-q": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz", + "integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==", + "dependencies": { + "@types/node": "16.9.1" + } + }, + "node_modules/image-q/node_modules/@types/node": { + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", + "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==" + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "engines": { + "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", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "dev": true, + "dependencies": { + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", + "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==" + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.1.tgz", + "integrity": "sha512-cub8rahkh0Q/bw1+GxP7aeSe29hHHn2V4m29nnDlvCdlgU+3UGxkZp7Z53jLUdpX3jdTO0nJZUDl3xvbWc2Xog==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", + "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", + "dev": true, + "dependencies": { + "@jest/core": "^27.5.1", + "import-local": "^3.0.2", + "jest-cli": "^27.5.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz", + "integrity": "sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "execa": "^5.0.0", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-27.5.1.tgz", + "integrity": "sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^0.7.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-circus/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-circus/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-circus/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-circus/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-circus/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-27.5.1.tgz", + "integrity": "sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw==", + "dev": true, + "dependencies": { + "@jest/core": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "import-local": "^3.0.2", + "jest-config": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "prompts": "^2.0.1", + "yargs": "^16.2.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-cli/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-cli/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-cli/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-cli/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-cli/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-config": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-27.5.1.tgz", + "integrity": "sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.8.0", + "@jest/test-sequencer": "^27.5.1", + "@jest/types": "^27.5.1", + "babel-jest": "^27.5.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.1", + "graceful-fs": "^4.2.9", + "jest-circus": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-jasmine2": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-config/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-config/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-config/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-config/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-diff": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-diff/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-diff/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-diff/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-diff/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-docblock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-27.5.1.tgz", + "integrity": "sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ==", + "dev": true, + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", + "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-each/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-each/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-each/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz", + "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1", + "jsdom": "^16.6.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz", + "integrity": "sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", + "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "@types/graceful-fs": "^4.1.2", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^27.5.1", + "jest-serializer": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "micromatch": "^4.0.4", + "walker": "^1.0.7" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-jasmine2": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", + "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-jasmine2/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-jasmine2/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-jasmine2/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-jasmine2/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-leak-detector": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz", + "integrity": "sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ==", + "dev": true, + "dependencies": { + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-matcher-utils/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-matcher-utils/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-message-util/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-message-util/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-message-util/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-message-util/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-mock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", + "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "resolve": "^1.20.0", + "resolve.exports": "^1.1.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz", + "integrity": "sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-snapshot": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-resolve/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-resolve/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-resolve/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-resolve/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-resolve/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runner": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-27.5.1.tgz", + "integrity": "sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ==", + "dev": true, + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-leak-detector": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "source-map-support": "^0.5.6", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runner/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-runner/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-runner/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-runner/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-runner/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runner/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runtime": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", + "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/globals": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "execa": "^5.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runtime/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-runtime/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-runtime/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-runtime/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-runtime/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-serializer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", + "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==", + "dev": true, + "dependencies": { + "@types/node": "*", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", + "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.7.2", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/traverse": "^7.7.2", + "@babel/types": "^7.0.0", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__traverse": "^7.0.4", + "@types/prettier": "^2.1.5", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "natural-compare": "^1.4.0", + "pretty-format": "^27.5.1", + "semver": "^7.3.2" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-snapshot/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-snapshot/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-snapshot/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-snapshot/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-util/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-util/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-util/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-util/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-util/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-validate": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", + "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "leven": "^3.1.0", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-validate/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-validate/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-validate/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-validate/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-validate/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-validate/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watcher": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-27.5.1.tgz", + "integrity": "sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw==", + "dev": true, + "dependencies": { + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "jest-util": "^27.5.1", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-watcher/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-watcher/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-watcher/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-watcher/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-watcher/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watcher/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", + "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", + "dev": true, + "dependencies": { + "abab": "^2.0.5", + "acorn": "^8.2.4", + "acorn-globals": "^6.0.0", + "cssom": "^0.4.4", + "cssstyle": "^2.3.0", + "data-urls": "^2.0.0", + "decimal.js": "^10.2.1", + "domexception": "^2.0.1", + "escodegen": "^2.0.0", + "form-data": "^3.0.0", + "html-encoding-sniffer": "^2.0.1", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.0.0", + "w3c-hr-time": "^1.0.2", + "w3c-xmlserializer": "^2.0.0", + "webidl-conversions": "^6.1.0", + "whatwg-encoding": "^1.0.5", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.5.0", + "ws": "^7.4.6", + "xml-name-validator": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/lit": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz", + "integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==", + "dependencies": { + "@lit/reactive-element": "^1.6.0", + "lit-element": "^3.3.0", + "lit-html": "^2.8.0" + } + }, + "node_modules/lit-element": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz", + "integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.1.0", + "@lit/reactive-element": "^1.3.0", + "lit-html": "^2.8.0" + } + }, + "node_modules/lit-html": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz", + "integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, + "node_modules/load-bmfont": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/load-bmfont/-/load-bmfont-1.4.2.tgz", + "integrity": "sha512-qElWkmjW9Oq1F9EI5Gt7aD9zcdHb9spJCW1L/dmPf7KzCCEJxq8nhHz5eCgI9aMf7vrG/wyaCqdsI+Iy9ZTlog==", + "dependencies": { + "buffer-equal": "0.0.1", + "mime": "^1.3.4", + "parse-bmfont-ascii": "^1.0.3", + "parse-bmfont-binary": "^1.0.5", + "parse-bmfont-xml": "^1.1.4", + "phin": "^3.7.1", + "xhr": "^2.0.1", + "xtend": "^4.0.0" + } + }, + "node_modules/load-bmfont/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-bmfont/node_modules/phin": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/phin/-/phin-3.7.1.tgz", + "integrity": "sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ==", + "dependencies": { + "centra": "^2.7.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.11", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", + "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/min-document": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", + "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==", + "dependencies": { + "dom-walk": "^0.1.0" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mrmime": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", + "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true + }, + "node_modules/node-vibrant": { + "version": "3.2.1-alpha.1", + "resolved": "https://registry.npmjs.org/node-vibrant/-/node-vibrant-3.2.1-alpha.1.tgz", + "integrity": "sha512-EQergCp7fvbvUCE0VMCBnvaAV0lGWSP8SXLmuWQIBzQK5M5pIwcd9fIOXuzFkJx/8hUiiiLvAzzGDS/bIy2ikA==", + "dependencies": { + "@types/node": "^10.12.18", + "@vibrant/core": "^3.2.1-alpha.1", + "@vibrant/generator-default": "^3.2.1-alpha.1", + "@vibrant/image-browser": "^3.2.1-alpha.1", + "@vibrant/image-node": "^3.2.1-alpha.1", + "@vibrant/quantizer-mmcq": "^3.2.1-alpha.1", + "url": "^0.11.0" + } + }, + "node_modules/node-vibrant/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nwsapi": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz", + "integrity": "sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==", + "dev": true + }, + "node_modules/object-inspect": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", + "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", + "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/omggif": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", + "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "dev": true + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-bmfont-ascii": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz", + "integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==" + }, + "node_modules/parse-bmfont-binary": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz", + "integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==" + }, + "node_modules/parse-bmfont-xml": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz", + "integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==", + "dependencies": { + "xml-parse-from-string": "^1.0.0", + "xml2js": "^0.5.0" + } + }, + "node_modules/parse-headers": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.5.tgz", + "integrity": "sha512-ft3iAoLOB/MlwbNXgzy43SWGP6sQki2jQvAyBg/zDFAgr9bfNWZIUj42Kw2eJIl8kEi4PbgE6U1Zau/HwI75HA==" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.0.tgz", + "integrity": "sha512-Qv32eSV1RSCfhY3fpPE2GNZ8jgM9X7rdAfemLWqTUxwiyIC4jJ6Sy0fZ8H+oLWevO6i4/bizg7c8d8i6bxrzbA==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/peek-readable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", + "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/phin": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/phin/-/phin-2.9.3.tgz", + "integrity": "sha512-CzFr90qM24ju5f88quFC/6qohjC144rehe5n6DH900lgXmUe86+xCKc10ev56gRKC4/BkHUoG4uSiQgBiIXwDA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info." + }, + "node_modules/picocolors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pixelmatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz", + "integrity": "sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA==", + "dependencies": { + "pngjs": "^3.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz", + "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==", + "dependencies": { + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve.exports": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.1.tgz", + "integrity": "sha512-/NtpHNDN7jWhAaQ9BvBUYZ6YTXsRBgfqWFWP7BZBaoMJO/I3G5OFzvTuWNlZC3aPjins1F+TNrLKsGbH4rfsRQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", + "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", + "dev": true, + "dependencies": { + "glob": "^11.0.0", + "package-json-from-dist": "^1.0.0" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.0.tgz", + "integrity": "sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^4.0.1", + "minimatch": "^10.0.0", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-serve": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/rollup-plugin-serve/-/rollup-plugin-serve-2.0.3.tgz", + "integrity": "sha512-gQKmfQng17+jOsX5tmDanvJkm0f9XLqWVvXsD7NGd1SlneT+U1j/HjslDUXQz6cqwLnVDRc6xF2lj6rre+eeeQ==", + "dev": true, + "dependencies": { + "mime": "^3", + "opener": "1" + } + }, + "node_modules/rollup-plugin-sourcemaps": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/rollup-plugin-sourcemaps/-/rollup-plugin-sourcemaps-0.6.3.tgz", + "integrity": "sha512-paFu+nT1xvuO1tPFYXGe+XnQvg4Hjqv/eIhG8i5EspfYYPBKL57X7iVbfv55aNVASg3dzWvES9dmWsL2KhfByw==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^3.0.9", + "source-map-resolve": "^0.6.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "@types/node": ">=10.0.0", + "rollup": ">=0.31.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/rollup-plugin-sourcemaps/node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, + "dependencies": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/rollup-plugin-sourcemaps/node_modules/@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true + }, + "node_modules/rollup-plugin-sourcemaps/node_modules/estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true + }, + "node_modules/rollup-plugin-terser": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", + "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "jest-worker": "^26.2.1", + "serialize-javascript": "^4.0.0", + "terser": "^5.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0" + } + }, + "node_modules/rollup-plugin-terser/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup-plugin-terser/node_modules/jest-worker": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", + "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/rollup-plugin-terser/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup-plugin-typescript2": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.36.0.tgz", + "integrity": "sha512-NB2CSQDxSe9+Oe2ahZbf+B4bh7pHwjV5L+RSYpCu7Q5ROuN94F9b6ioWwKfz3ueL3KTtmX4o2MUH2cgHDIEUsw==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^4.1.2", + "find-cache-dir": "^3.3.2", + "fs-extra": "^10.0.0", + "semver": "^7.5.4", + "tslib": "^2.6.2" + }, + "peerDependencies": { + "rollup": ">=1.26.3", + "typescript": ">=2.4.0" + } + }, + "node_modules/rollup-plugin-typescript2/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/rollup-plugin-typescript2/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-regex-test": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-regex": "^1.1.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==" + }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", + "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", + "dev": true, + "dependencies": { + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.72.1" + } + }, + "node_modules/source-map-loader/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-resolve": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz", + "integrity": "sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==", + "deprecated": "See https://github.com/lydell/source-map-resolve#deprecated", + "dev": true, + "dependencies": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strtok3": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", + "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "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", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, + "node_modules/synckit": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.1.tgz", + "integrity": "sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==", + "dev": true, + "dependencies": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.31.6", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.6.tgz", + "integrity": "sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.20", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.26.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/throat": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz", + "integrity": "sha512-WKexMoJj3vEuK0yFEapj8y64V0A6xcuPuK9Gt1d0R+dzCSJc0lHqQytAbSB4cDAK0dWh4T0E2ETkoLE2WZ41OQ==", + "dev": true + }, + "node_modules/timm": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/timm/-/timm-1.7.1.tgz", + "integrity": "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==" + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/token-types": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", + "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/tr46": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", + "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", + "dev": true, + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-loader": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.1.tgz", + "integrity": "sha512-rNH3sK9kGZcH9dYzC7CewQm4NtxJTjSEVRJ2DyBZR7f8/wcta+iV44UPCXc5+nzDzivKtlzV6c9P4e+oFhDLYg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/ts-loader/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ts-loader/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ts-loader/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ts-loader/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/ts-loader/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ts-loader/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-loader/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ts-loader/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.1.0.tgz", + "integrity": "sha512-xWFISjviPydmtmgeUAuXp4N1fky+VCtfhOkDUFIv5ea7p4wuTomI4QTrXvFBX2S4jZsmyTSrStQl+E+4w+RzxA==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", + "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.2", + "picocolors": "^1.0.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "dependencies": { + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/url/node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==" + }, + "node_modules/utif": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/utif/-/utif-2.0.1.tgz", + "integrity": "sha512-Z/S1fNKCicQTf375lIP9G8Sa1H/phcysstNrrSdZKj1f9g58J4NMgb5IgiEZN9/nLMPDwF0W7hdOe9Qq2IYoLg==", + "dependencies": { + "pako": "^1.0.5" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/v8-to-istanbul": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", + "integrity": "sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^1.6.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, + "node_modules/v8-to-istanbul/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/w3c-hr-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", + "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", + "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.", + "dev": true, + "dependencies": { + "browser-process-hrtime": "^1.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", + "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/watchpack": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webidl-conversions": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", + "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", + "dev": true, + "engines": { + "node": ">=10.4" + } + }, + "node_modules/webpack": { + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", + "acorn": "^8.7.1", + "acorn-import-attributes": "^1.9.5", + "browserslist": "^4.21.10", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.1", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", + "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", + "dev": true, + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "debounce": "^1.2.1", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "html-escaper": "^2.0.2", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/acorn-walk": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz", + "integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==", + "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-cli": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", + "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", + "dev": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^2.1.1", + "@webpack-cli/info": "^2.0.2", + "@webpack-cli/serve": "^2.0.5", + "colorette": "^2.0.14", + "commander": "^10.0.1", + "cross-spawn": "^7.0.3", + "envinfo": "^7.7.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/whatwg-encoding": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", + "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", + "dev": true, + "dependencies": { + "iconv-lite": "0.4.24" + } + }, + "node_modules/whatwg-mimetype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", + "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", + "dev": true + }, + "node_modules/whatwg-url": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", + "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", + "dev": true, + "dependencies": { + "lodash": "^4.7.0", + "tr46": "^2.1.0", + "webidl-conversions": "^6.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/worker-loader": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-3.0.8.tgz", + "integrity": "sha512-XQyQkIFeRVC7f7uRhFdNMe/iJOdO6zxAaR3EWbDp45v3mDhrTi+++oswKNxShUNjPC/1xUp5DB29YKLhFo129g==", + "dev": true, + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xhr": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/xhr/-/xhr-2.6.0.tgz", + "integrity": "sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA==", + "dependencies": { + "global": "~4.4.0", + "is-function": "^1.0.1", + "parse-headers": "^2.0.0", + "xtend": "^4.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", + "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", + "dev": true + }, + "node_modules/xml-parse-from-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", + "integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==" + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..14d8fb5 --- /dev/null +++ b/package.json @@ -0,0 +1,72 @@ +{ + "name": "spotifyplus-card", + "version": "", + "description": "", + "main": "src/main.js", + "module": "src/main.js", + "repository": "", + "author": "Todd Lucas", + "license": "MIT", + "dependencies": { + "@mdi/js": "^7.4.47", + "@vibrant/color": "^3.2.1-alpha.1", + "@vibrant/core": "^3.2.1-alpha.1", + "@vibrant/generator-default": "^3.2.1-alpha.1", + "@vibrant/image": "^3.2.1-alpha.1", + "@vibrant/image-browser": "^3.2.1-alpha.1", + "@vibrant/image-node": "^3.2.1-alpha.1", + "@vibrant/quantizer-mmcq": "^3.2.1-alpha.1", + "custom-card-helpers": "^1.9.0", + "debug": "^4.3.7", + "lit": "^2.8.0", + "node-vibrant": "^3.2.1-alpha.1", + "url": "^0.11.4" + }, + "devDependencies": { + "@babel/core": "^7.24.4", + "@material/web": "^2.1.0", + "@rollup/plugin-babel": "^6.0.4", + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.3.0", + "@rollup/plugin-typescript": "^12.1.0", + "@types/debug": "^4.1.12", + "@types/jest": "27.4.1", + "@types/query-selector-shadow-dom": "^1.0.4", + "@typescript-eslint/eslint-plugin": "^7.6.0", + "@typescript-eslint/parser": "^7.7.0", + "eslint": "^8.57.1", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.30.0", + "eslint-plugin-prettier": "^5.1.3", + "jest": "27.5.1", + "prettier": "^3.2.5", + "rimraf": "^6.0.1", + "rollup": "^2.79.2", + "rollup-plugin-serve": "^2.0.3", + "rollup-plugin-sourcemaps": "^0.6.3", + "rollup-plugin-terser": "^7.0.2", + "rollup-plugin-typescript2": "^0.36.0", + "source-map-loader": "^5.0.0", + "ts-loader": "^9.5.1", + "tsconfig-paths-webpack-plugin": "^4.1.0", + "typescript": "^4.9.5", + "webpack": "^5.94.0", + "webpack-bundle-analyzer": "^4.10.2", + "webpack-cli": "^5.1.4", + "worker-loader": "^3.0.8" + }, + "scripts": { + "start": "rollup -c --watch", + "build": "npm run lint && npm run rollup && npm run deploy-vm", + "buildgithub": "npm run lint && npm run rollup", + "deploy-prod": "npm run build && cp dist/spotifyplus-card.js ~/homeassistant/www/community/spotifyplus-card/spotifyplus-card.js", + "deploy-vm": "xcopy \"dist\\spotifyplus-card.*\" \"\\\\homeassistantvm\\config\\www\" /Y /-I", + "lint": "eslint src/*.ts", + "rollup": "rollup -c", + "clean": "npm run clean_delfiles && npm run clean_deldirs", + "clean_delfiles": "del /S /Q \"node_modules\\.cache\\rollup-plugin-typescript2\\*.*\"", + "clean_deldirs": "for /d %G in (\"node_modules\\.cache\\rollup-plugin-typescript2\\rpt2_*\") do rd /s /q \"%~G\"" + } +} diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..a8b7dfd --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,51 @@ +import typescript from 'rollup-plugin-typescript2'; +import commonjs from '@rollup/plugin-commonjs'; +import nodeResolve from '@rollup/plugin-node-resolve'; +import babel from '@rollup/plugin-babel'; +import { terser } from 'rollup-plugin-terser'; +import serve from 'rollup-plugin-serve'; +import json from '@rollup/plugin-json'; + +const serveopts = { + contentBase: ['./dist'], + host: '0.0.0.0', + port: 5000, + allowCrossOrigin: true, + headers: { + 'Access-Control-Allow-Origin': '*', + }, +}; + +const plugins = [ + nodeResolve({}), + commonjs(), + //typescript(), + typescript({ + sourceMap: false, // Generate sourcemaps + inlineSources: false // Include source code in sourcemaps + }), + //terser() // Optional: Minify the output + json(), + babel({ + exclude: 'node_modules/**', + babelHelpers: 'bundled', + }), + process.env.ROLLUP_WATCH && serve(serveopts), + !process.env.ROLLUP_WATCH && !process.env.DEV && terser(), +]; + +export default [ + { + input: 'src/main.ts', + output: { + file: 'dist/spotifyplus-card.js', + format: 'es', + sourcemap: true, + }, + onwarn(warning, warn) { + if (warning.code === 'THIS_IS_UNDEFINED') return; + warn(warning); + }, + plugins: [...plugins], + }, +]; diff --git a/src/card.ts b/src/card.ts new file mode 100644 index 0000000..47bf98c --- /dev/null +++ b/src/card.ts @@ -0,0 +1,1029 @@ +// 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, state } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; +import { when } from 'lit/directives/when.js'; + +// our imports - card sections and editor. +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/device-browser'; // SECTION.DEVICES +import './sections/episode-fav-browser'; // SECTION.EPISODE_FAVORITES +import './sections/player'; // SECTION.PLAYER +import './sections/playlist-fav-browser'; // SECTION.PLAYLIST_FAVORITES +import './sections/recent-browser'; // SECTION.RECENTS +import './sections/search-media-browser'; // SECTION.SEARCH_MEDIA +import './sections/show-fav-browser'; // SECTION.SHOW_FAVORITES +import './sections/track-fav-browser'; // SECTION.TRACK_FAVORITES +import './sections/userpreset-browser'; // SECTION.USERPRESETS +import './components/footer'; +import './editor/editor'; + +// our imports. +import { EDITOR_CONFIG_AREA_SELECTED, EditorConfigAreaSelectedEventArgs } from './events/editor-config-area-selected'; +import { PROGRESS_STARTED } from './events/progress-started'; +import { PROGRESS_ENDED } from './events/progress-ended'; +import { Store } from './model/store'; +import { CardConfig } from './types/card-config'; +import { CustomImageUrls } from './types/custom-image-urls'; +import { ConfigArea } from './types/config-area'; +import { Section } from './types/section'; +import { formatTitleInfo, removeSpecialChars } from './utils/media-browser-utils'; +import { BRAND_LOGO_IMAGE_BASE64, BRAND_LOGO_IMAGE_SIZE } from './constants'; +import { + getConfigAreaForSection, + getSectionForConfigArea, + isCardInEditPreview, + isCardInDashboardEditor, + isCardInPickerPreview, + isNumber, +} from './utils/utils'; + +const HEADER_HEIGHT = 2; +const FOOTER_HEIGHT = 4; +const CARD_DEFAULT_HEIGHT = '35.15rem'; +const CARD_DEFAULT_WIDTH = '35.15rem'; +const CARD_EDIT_PREVIEW_HEIGHT = '42rem'; +const CARD_EDIT_PREVIEW_WIDTH = '100%'; +const CARD_PICK_PREVIEW_HEIGHT = '100%'; +const CARD_PICK_PREVIEW_WIDTH = '100%'; + +const EDIT_TAB_HEIGHT = '48px'; +const EDIT_BOTTOM_TOOLBAR_HEIGHT = '59px'; + +// Good source of help documentation on HA custom cards: +// https://gist.github.com/thomasloven/1de8c62d691e754f95b023105fe4b74b + + +@customElement("spotifyplus-card") +export class Card extends LitElement { + + /** + * Home Assistant will update the hass property of the config element on state changes, and + * the lovelace config element, which contains information about the dashboard configuration. + * + * Whenever anything updates in Home Assistant, the hass object is updated and passed out + * to every card. If you want to react to state changes, this is where you do it. If not, + * you can just ommit this setter entirely. + * Note that if you do NOT have a `set hass(hass)` in your class, you can access the hass + * object through `this.hass`. But if you DO have it, you need to save the hass object + * manually, like so: + * `this._hass = hass;` + * */ + + // public state properties. + @property({ attribute: false }) public hass!: HomeAssistant; + @property({ attribute: false }) config!: CardConfig; + @property({ attribute: false }) footerBackgroundColor?: string; + + // private state properties. + @state() private section!: Section; + @state() private store!: Store; + @state() private showLoader!: boolean; + @state() private loaderTimestamp!: number; + @state() private cancelLoader!: boolean; + @state() private playerId!: string; + + /** Indicates if createStore method is executing for the first time (true) or not (false). */ + private isFirstTimeSetup: boolean = true; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(); + + // initialize storage. + this.showLoader = false; + this.cancelLoader = false; + this.loaderTimestamp = 0; + + } + + + /** + * 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 { + + // just in case hass property has not been set yet. + if (!this.hass) + return html``; + + // note that this cannot be called from `setConfig` method, as the `hass` property + // has not been set set. + this.createStore(); + + // if no sections are configured then configure the default. + if (!this.config.sections || this.config.sections.length === 0) { + this.config.sections = [Section.PLAYER]; + Store.selectedConfigArea = ConfigArea.GENERAL; + } + + //console.log("render (card) - rendering card\n- this.store.section=%s\n- this.section=%s\n- Store.selectedConfigArea=%s\n- playerId=%s\n- config.sections=%s", + // JSON.stringify(this.store.section), + // JSON.stringify(this.section), + // JSON.stringify(Store.selectedConfigArea), + // JSON.stringify(this.playerId), + // JSON.stringify(this.config.sections), + //); + + // calculate height of the card, accounting for any extra + // titles that are shown, footer, etc. + const sections = this.config.sections; + const showFooter = !sections || sections.length > 1; + const title = formatTitleInfo(this.config.title, this.config, this.store.player); + + // render html for the card. + return html` + +
+ +
+ ${title ? html`
${title}
` : html``} +
+ ${ + this.playerId + ? choose(this.section, [ + [Section.ALBUM_FAVORITES, () => html``], + [Section.ARTIST_FAVORITES, () => html``], + [Section.AUDIOBOOK_FAVORITES, () => html``], + [Section.DEVICES, () => html``], + [Section.EPISODE_FAVORITES, () => html``], + [Section.PLAYER, () => html``], + [Section.PLAYLIST_FAVORITES, () => html``], + [Section.RECENTS, () => html``], + [Section.SEARCH_MEDIA, () => 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.
` + // : choose(this.section, [ + // [Section.INITIAL_CONFIG, () => html`
Welcome to the SpotifyPlus media player card.
Please start by configuring the card.
`], + // [Section.UNDEFINED, () => html`
SpotifyPlus card configuration error.
Please check the card configuration.
`], + // ]) + } +
+ ${when(showFooter, () => + html`` + )} +
+ `; + } + + + /** + * Style definitions used by this card. + */ + static get styles() { + return css` + :host { + display: block; + width: 100% !important; + height: 100% !important; + } + + * { + margin: 0; + } + + html, + body { + height: 100%; + margin: 0; + } + + spotifyplus-card { + display: block; + height: 100% !important; + width: 100% !important; + } + + hui-card-preview { + min-height: 10rem; + height: 40rem; + min-width: 10rem; + width: 40rem; + } + + .spc-card { + --spc-card-header-height: ${HEADER_HEIGHT}rem; + --spc-card-footer-height: ${FOOTER_HEIGHT}rem; + --spc-card-edit-tab-height: 0px; + --spc-card-edit-bottom-toolbar-height: 0px; + box-sizing: border-box; + color: var(--secondary-text-color); + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 20rem; + height: calc(100vh - var(--spc-card-footer-height) - var(--spc-card-edit-tab-height) - var(--spc-card-edit-bottom-toolbar-height)); + min-width: 20rem; + width: calc(100vw - var(--mdc-drawer-width)); + } + + .spc-card-header { + margin: 0.2rem; + display: flex; + align-self: flex-start; + align-items: center; + justify-content: space-around; + width: 100%; + font-weight: bold; + font-size: 1.2rem; + color: var(--secondary-text-color); + } + + .spc-card-content-section { + margin: 0.0rem; + flex-grow: 1; + flex-shrink: 0; + height: 1vh; + overflow: hidden; + } + + .spc-card-footer-container { + width: 100%; + display: flex; + align-items: center; + background-repeat: no-repeat; + } + + .spc-card-footer { + margin: 0.2rem; + display: flex; + align-self: flex-start; + align-items: center; + justify-content: space-around; + width: 100%; + --mdc-icon-size: 1.75rem; + --mdc-icon-button-size: 2.5rem; + --mdc-ripple-top: 0px; + --mdc-ripple-left: 0px; + --mdc-ripple-fg-size: 10px; + } + + .spc-loader { + position: absolute; + z-index: 1000; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + --mdc-theme-primary: var(--dark-primary-color); + } + + .spc-not-configured { + text-align: center; + margin-top: 1rem; + } + + .spc-initial-config { + text-align: center; + margin-top: 1rem; + } + + ha-icon-button { + padding-left: 1rem; + padding-right: 1rem; + } + + ha-circular-progress { + --md-sys-color-primary: var(--dark-primary-color); + } + `; + } + + + /** + * Creates the common services and data areas that are used by the various card sections. + * + * Note that this method cannot be called from `setConfig` method, as the `hass` property + * has not been set set! + */ + private createStore() { + + // create the store. + this.store = new Store(this.hass, this.config, this, this.section, this.config.entity); + + // have we set the player id yet? if not, then make it so. + if (!this.playerId) { + this.playerId = this.config.entity; + } + + // is this the first time executing? + if ((this.isFirstTimeSetup) && (this.playerId)) { + + // if there are things that you only want to happen one time when the configuration + // is initially loaded, then do them here. + + //console.log("createStore (card) - isFirstTimeSetup logic invoked"); + + // set the initial section reference; if none defined, then default; + if ((!this.config.sections) || (this.config.sections.length == 0)) { + + this.config.sections = [Section.PLAYER]; + this.section = Section.PLAYER; + this.store.section = this.section; + Store.selectedConfigArea = ConfigArea.GENERAL; + super.requestUpdate(); + + } else if (!this.section) { + + // section was not set; set section selected based on selected ConfigArea. + this.section = getSectionForConfigArea(Store.selectedConfigArea); + this.store.section = this.section; + super.requestUpdate(); + + } + + // indicate first time setup has completed. + this.isFirstTimeSetup = false; + } + } + + + /** + * Sets the section value and requests an update to show the section. + * + * @param section Section to show. + */ + public SetSection(section: Section): void { + + // is the session configured for display? + if (!this.config.sections || this.config.sections.indexOf(section) > -1) { + + this.section = section; + this.store.section = this.section; + super.requestUpdate(); + + } + } + + + /** + * Invoked when the component is added to the document's DOM. + * + * In `connectedCallback()` you should setup tasks that should only occur when + * the element is connected to the document. The most common of these is + * adding event listeners to nodes external to the element, like a keydown + * event handler added to the window. + * + * Typically, anything done in `connectedCallback()` should be undone when the + * element is disconnected, in `disconnectedCallback()`. + */ + public connectedCallback() { + + // invoke base class method. + super.connectedCallback(); + + // add control level event listeners. + this.addEventListener(PROGRESS_ENDED, this.onProgressEndedEventHandler); + this.addEventListener(PROGRESS_STARTED, this.onProgressStartedEventHandler); + + // only add the following events if card configuration is being edited. + if (isCardInEditPreview(this)) { + + // add document level event listeners. + document.addEventListener(EDITOR_CONFIG_AREA_SELECTED, this.OnEditorConfigAreaSelectedEventHandler); + + } + + } + + + /** + * Invoked when the component is removed from the document's DOM. + * + * This callback is the main signal to the element that it may no longer be + * used. `disconnectedCallback()` should ensure that nothing is holding a + * reference to the element (such as event listeners added to nodes external + * to the element), so that it is free to be garbage collected. + * + * An element may be re-connected after being disconnected. + */ + public disconnectedCallback() { + + // remove control level event listeners. + this.removeEventListener(PROGRESS_ENDED, this.onProgressEndedEventHandler); + this.removeEventListener(PROGRESS_STARTED, this.onProgressStartedEventHandler); + + // the following event is only added when the card configuration editor is created. + // always remove the following events, as isCardInEditPreview() can sometimes + // return a different value than when the event was added in connectedCallback! + + // remove document level event listeners. + document.removeEventListener(EDITOR_CONFIG_AREA_SELECTED, this.OnEditorConfigAreaSelectedEventHandler); + + // invoke base class method. + super.disconnectedCallback(); + } + + + /** + * 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); + + //console.log("firstUpdated (card) - 1st render complete - changedProperties keys:\n- %s", + // JSON.stringify(Array.from(changedProperties.keys())), + //); + + // if there are things that you only want to happen one time when the configuration + // is initially loaded, then do them here. + + // at this point, the first render has occurred. + // ensure that the specified section is configured; if not, find the first available + // section that IS configured and display it. + const sectionsConfigured = this.config.sections || [] + if (!sectionsConfigured.includes(this.section)) { + + // find the first active section, as determined by the order listed in the footer. + let sectionNew: Section = Section.PLAYER; + if (sectionsConfigured.includes(Section.PLAYER)) { + sectionNew = Section.PLAYER; + } else if (sectionsConfigured.includes(Section.DEVICES)) { + sectionNew = Section.DEVICES; + } else if (sectionsConfigured.includes(Section.USERPRESETS)) { + sectionNew = Section.USERPRESETS; + } else if (sectionsConfigured.includes(Section.RECENTS)) { + sectionNew = Section.RECENTS; + } else if (sectionsConfigured.includes(Section.PLAYLIST_FAVORITES)) { + sectionNew = Section.PLAYLIST_FAVORITES; + } else if (sectionsConfigured.includes(Section.ALBUM_FAVORITES)) { + sectionNew = Section.ALBUM_FAVORITES; + } else if (sectionsConfigured.includes(Section.ARTIST_FAVORITES)) { + sectionNew = Section.ARTIST_FAVORITES; + } else if (sectionsConfigured.includes(Section.TRACK_FAVORITES)) { + sectionNew = Section.TRACK_FAVORITES; + } else if (sectionsConfigured.includes(Section.AUDIOBOOK_FAVORITES)) { + sectionNew = Section.AUDIOBOOK_FAVORITES; + } else if (sectionsConfigured.includes(Section.SHOW_FAVORITES)) { + sectionNew = Section.SHOW_FAVORITES; + } else if (sectionsConfigured.includes(Section.EPISODE_FAVORITES)) { + sectionNew = Section.EPISODE_FAVORITES; + } else if (sectionsConfigured.includes(Section.SEARCH_MEDIA)) { + sectionNew = Section.SEARCH_MEDIA; + } + + // set the default editor configarea value, so that if the card is edited + // it will automatically select the configuration settings for the section. + Store.selectedConfigArea = getConfigAreaForSection(sectionNew); + + // show the rendered section. + this.section = sectionNew; + this.store.section = sectionNew; + super.requestUpdate(); + + } else if (isCardInEditPreview(this)) { + + // if in edit mode, then refresh display as card size is different. + super.requestUpdate(); + } + + } + + + /** + * Handles the `PROGRESS_ENDED` event. + * This will hide the circular progress indicator on the main card display. + * + * This event has no arguments. + */ + protected onProgressEndedEventHandler = () => { + + this.cancelLoader = true; + const duration = Date.now() - this.loaderTimestamp; + + // is the progress loader icon visible? + if (this.showLoader) { + + if (duration < 1000) { + // progress will hide in less than 1 second. + setTimeout(() => (this.showLoader = false), 1000 - duration); + } else { + this.showLoader = false; + // progress is hidden. + } + } + } + + + /** + * Handles the `PROGRESS_STARTED` event. + * This will show the circular progress indicator on the main card display for lengthy operations. + * + * A delay of 250 milliseconds is executed before the progress indicator is shown - if the progress + * done event is received in this delay period, then the progress indicator is not shown. This + * keeps the progress indicator from "flickering" for operations that are quick to respond. + * + * @param ev Event definition and arguments. + */ + protected onProgressStartedEventHandler = () => { + + // is progress bar currently shown? if not, then make it so. + if (!this.showLoader) { + + this.cancelLoader = false; + + // wait just a bit before showing the progress indicator; if the progress done event is received + // in this delay period, then the progress indicator is not shown. + setTimeout(() => { + if (!this.cancelLoader) { + this.showLoader = true; + this.loaderTimestamp = Date.now(); + // progress is showing. + } else { + // progress was cancelled before it had to be shown. + } + }, 250); + + } + } + + + /** + * Handles the card configuration editor `EDITOR_CONFIG_AREA_SELECTED` event. + * + * This will select a section for display / rendering. + * This event should only be fired from the configuration editor instance. + * + * @param ev Event definition and arguments. + */ + protected OnEditorConfigAreaSelectedEventHandler = (ev: Event) => { + + // map event arguments. + const evArgs = (ev as CustomEvent).detail as EditorConfigAreaSelectedEventArgs; + + // is section activated? if so, then select it. + if (this.config.sections?.includes(evArgs.section)) { + + this.section = evArgs.section; + this.store.section = this.section; + + } else { + + // section is not activated. + + } + } + + + /** + * Handles the footer `show-section` event. + * + * This will change the `section` attribute value to the value supplied, which will also force + * a refresh of the card and display the selected section. + * + * @param args Event arguments that contain the section to show. + */ + protected OnFooterShowSection = (args: CustomEvent) => { + + const section = args.detail; + if (!this.config.sections || this.config.sections.indexOf(section) > -1) { + + this.section = section; + this.store.section = this.section; + super.requestUpdate(); + + } else { + + // specified section is not active. + + } + } + + + /** + * Handles the Media List `item-selected` event. + * + * @param args Event arguments (none passed). + */ + protected onMediaListItemSelected = () => { + + // don't need to do anything here, as the section will show the player. + // left this code here though, in case we want to do something else after + // an item is selected. + + // example: show the card Player section (after a slight delay). + //setTimeout(() => (this.SetSection(Section.PLAYER)), 1500); + + } + + + /** + * Home Assistant will call setConfig(config) when the configuration changes. This + * is most likely to occur when changing the configuration via the UI editor, but + * can also occur if YAML changes are made (for cards without UI config editor). + * + * If you throw an exception in this method (e.g. invalid configuration, etc), then + * Home Assistant will render an error card to notify the user. Note that by doing + * so will also disable the Card Editor UI, and the card must be configured manually! + * + * The config argument object contains the configuration specified by the user for + * the card. It will minimally contain: + * `config.type = "custom:my-custom-card"` + * + * The `setConfig` method MUST be defined, and is in fact the only function that must be. + * It doesn't need to actually DO anything, though. + * + * Note that setConfig will ALWAYS be called at the start of the lifetime of the card + * BEFORE the `hass` object is first provided. It MAY be called several times during + * the lifetime of the card, e.g. if the configuration of the card is changed. + * + * We use it here to update the internal config property, as well as perform some + * basic validation and initialization of the config. + * + * @param config Contains the configuration specified by the user for the card. + */ + public setConfig(config: CardConfig): void { + + //console.log("setConfig (card) - configuration change\n- this.section=%s\n- Store.selectedConfigArea=%s", + // JSON.stringify(this.section), + // JSON.stringify(Store.selectedConfigArea), + //); + + // copy the passed configuration object to create a new instance. + const newConfig: CardConfig = JSON.parse(JSON.stringify(config)); + + // remove any configuration properties that do not have a value set. + for (const [key, value] of Object.entries(newConfig)) { + if (Array.isArray(value) && value.length === 0) { + // removing empty config value. + delete newConfig[key]; + } + } + + // default configration values if not set. + newConfig.albumFavBrowserItemsPerRow = newConfig.albumFavBrowserItemsPerRow || 4; + newConfig.albumFavBrowserItemsHideTitle = newConfig.albumFavBrowserItemsHideTitle || false; + newConfig.albumFavBrowserItemsSortTitle = newConfig.albumFavBrowserItemsSortTitle || false; + + newConfig.artistFavBrowserItemsPerRow = newConfig.artistFavBrowserItemsPerRow || 4; + newConfig.artistFavBrowserItemsHideTitle = newConfig.artistFavBrowserItemsHideTitle || false; + newConfig.artistFavBrowserItemsSortTitle = newConfig.artistFavBrowserItemsSortTitle || false; + + newConfig.audiobookFavBrowserItemsPerRow = newConfig.audiobookFavBrowserItemsPerRow || 4; + newConfig.audiobookFavBrowserItemsHideTitle = newConfig.audiobookFavBrowserItemsHideTitle || false; + newConfig.audiobookFavBrowserItemsSortTitle = newConfig.audiobookFavBrowserItemsSortTitle || false; + + newConfig.deviceBrowserItemsPerRow = newConfig.deviceBrowserItemsPerRow || 1; + newConfig.deviceBrowserItemsHideSubTitle = newConfig.deviceBrowserItemsHideSubTitle || false; + newConfig.deviceBrowserItemsHideTitle = newConfig.deviceBrowserItemsHideTitle || false; + + newConfig.playerHeaderHide = newConfig.playerHeaderHide || false; + newConfig.playerHeaderHideProgressBar = newConfig.playerHeaderHideProgressBar || false; + newConfig.playerControlsHideFavorites = newConfig.playerControlsHideFavorites || false; + newConfig.playerControlsHidePlayPause = newConfig.playerControlsHidePlayPause || false; + newConfig.playerControlsHideRepeat = newConfig.playerControlsHideRepeat || false; + newConfig.playerControlsHideShuffle = newConfig.playerControlsHideShuffle || false; + newConfig.playerControlsHideTrackNext = newConfig.playerControlsHideTrackNext || false; + newConfig.playerControlsHideTrackPrev = newConfig.playerControlsHideTrackPrev || false; + + newConfig.playlistFavBrowserItemsPerRow = newConfig.playlistFavBrowserItemsPerRow || 4; + newConfig.playlistFavBrowserItemsHideTitle = newConfig.playlistFavBrowserItemsHideTitle || false; + newConfig.playlistFavBrowserItemsSortTitle = newConfig.playlistFavBrowserItemsSortTitle || false; + + newConfig.recentBrowserItemsPerRow = newConfig.recentBrowserItemsPerRow || 4; + newConfig.recentBrowserItemsHideSubTitle = newConfig.recentBrowserItemsHideSubTitle || false; + newConfig.recentBrowserItemsHideTitle = newConfig.recentBrowserItemsHideTitle || false; + + newConfig.showFavBrowserItemsPerRow = newConfig.showFavBrowserItemsPerRow || 4; + newConfig.showFavBrowserItemsHideTitle = newConfig.showFavBrowserItemsHideTitle || false; + newConfig.showFavBrowserItemsSortTitle = newConfig.showFavBrowserItemsSortTitle || false; + + newConfig.trackFavBrowserItemsPerRow = newConfig.trackFavBrowserItemsPerRow || 4; + newConfig.trackFavBrowserItemsHideTitle = newConfig.trackFavBrowserItemsHideTitle || false; + newConfig.trackFavBrowserItemsSortTitle = newConfig.trackFavBrowserItemsSortTitle || false; + + newConfig.userPresetBrowserItemsPerRow = newConfig.userPresetBrowserItemsPerRow || 4; + newConfig.userPresetBrowserItemsHideSubTitle = newConfig.userPresetBrowserItemsHideSubTitle || false; + newConfig.userPresetBrowserItemsHideTitle = newConfig.userPresetBrowserItemsHideTitle || false; + + // if custom imageUrl's are supplied, then remove special characters from each title + // to speed up comparison when imageUrl's are loaded later on. we will also + // replace any spaces in the imageUrl with "%20" to make it url friendly. + const customImageUrlsTemp = {}; + for (const itemTitle in (newConfig.customImageUrls)) { + const title = removeSpecialChars(itemTitle); + let imageUrl = newConfig.customImageUrls[itemTitle]; + imageUrl = imageUrl?.replace(' ', '%20'); + customImageUrlsTemp[title] = imageUrl; + } + newConfig.customImageUrls = customImageUrlsTemp; + + // if no sections are configured then configure the default. + if (!newConfig.sections || newConfig.sections.length === 0) { + newConfig.sections = [Section.PLAYER]; + Store.selectedConfigArea = ConfigArea.GENERAL; + } + + // store configuration so other card sections can access them. + this.config = newConfig; + + //console.log("setConfig (card) - configuration changes applied\n- this.section=%s\n- Store.selectedConfigArea=%s", + // JSON.stringify(this.section), + // JSON.stringify(Store.selectedConfigArea), + //); + + //console.log("setConfig (card) - updated configuration:\n%s", + // JSON.stringify(this.config,null,2), + //); + + } + + + /** + * Returns the size of the card as a number or a promise that will resolve to a number. + * A height of 1 is equivalent to 50 pixels. + * This will help Home Assistant distribute the cards evenly over the columns. + * A card size of 1 will be assumed if the method is not defined. + */ + getCardSize() { + return 3; + } + + + /** + * Returns a custom element for editing the user configuration. + * + * Home Assistant will display this element in the card editor in the dashboard, along with + * the rendered card (to the right of the editor). + */ + public static getConfigElement() { + + // initialize what configarea to display on entry - always GENERAL, since this is a static method. + Store.selectedConfigArea = ConfigArea.GENERAL; + + // clear card editor first render settings. + Store.hasCardEditLoadedMediaList = {}; + + // get the card configuration editor, and return for display. + return document.createElement('spc-editor'); + } + + + /** + * Returns a default card configuration (without the type: parameter) in json form + * for use by the card type picker in the dashboard. + * + * Use this method to generate the initial configuration; assign defaults, omit + * parameters that are optional, etc. + */ + 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], + entity: "", + + playerHeaderTitle: "{player.source}", + playerHeaderArtistTrack: "{player.media_artist} - {player.media_title}", + playerHeaderAlbum: "{player.media_album_name}", + playerHeaderNoMediaPlayingText: "\"{player.name}\" state is \"{player.state}\"", + + albumFavBrowserTitle: "Album Favorites for {player.sp_user_display_name} ({medialist.itemcount} items)", + albumFavBrowserSubTitle: "click a tile item to play the content; click-hold for actions", + albumFavBrowserItemsPerRow: 4, + albumFavBrowserItemsHideTitle: false, + albumFavBrowserItemsHideSubTitle: false, + albumFavBrowserItemsSortTitle: true, + + artistFavBrowserTitle: "Artist Favorites for {player.sp_user_display_name} ({medialist.itemcount} items)", + artistFavBrowserSubTitle: "click a tile item to play the content; click-hold for actions", + artistFavBrowserItemsPerRow: 4, + artistFavBrowserItemsHideTitle: false, + artistFavBrowserItemsHideSubTitle: true, + artistFavBrowserItemsSortTitle: true, + + audiobookFavBrowserTitle: "Audiobook Favorites for {player.sp_user_display_name} ({medialist.itemcount} items)", + audiobookFavBrowserSubTitle: "click a tile item to play the content; click-hold for actions", + audiobookFavBrowserItemsPerRow: 4, + audiobookFavBrowserItemsHideTitle: false, + audiobookFavBrowserItemsHideSubTitle: false, + audiobookFavBrowserItemsSortTitle: true, + + deviceBrowserTitle: "Spotify Connect Devices ({medialist.itemcount} items)", + deviceBrowserSubTitle: "click an item to select the device; click-hold for device info", + deviceBrowserItemsPerRow: 1, + deviceBrowserItemsHideTitle: false, + deviceBrowserItemsHideSubTitle: true, + + episodeFavBrowserTitle: "Episode Favorites for {player.sp_user_display_name} ({medialist.itemcount} items)", + episodeFavBrowserSubTitle: "click a tile item to play the content; click-hold for actions", + episodeFavBrowserItemsPerRow: 4, + episodeFavBrowserItemsHideTitle: false, + episodeFavBrowserItemsHideSubTitle: false, + episodeFavBrowserItemsSortTitle: true, + + playlistFavBrowserTitle: "Playlist Favorites for {player.sp_user_display_name} ({medialist.itemcount} items)", + playlistFavBrowserSubTitle: "click a tile item to play the content; click-hold for actions", + playlistFavBrowserItemsPerRow: 4, + playlistFavBrowserItemsHideTitle: false, + playlistFavBrowserItemsHideSubTitle: false, + playlistFavBrowserItemsSortTitle: true, + + recentBrowserTitle: "Recently Played by {player.sp_user_display_name} ({medialist.itemcount} items)", + recentBrowserSubTitle: "click a tile item to play the content; click-hold for actions", + recentBrowserItemsPerRow: 4, + recentBrowserItemsHideTitle: false, + recentBrowserItemsHideSubTitle: false, + + searchMediaBrowserTitle: "Search Media for {player.sp_user_display_name} ({medialist.itemcount} items)", + searchMediaBrowserSubTitle: "click a tile item to play the content; click-hold for actions", + searchMediaBrowserUseDisplaySettings: false, + searchMediaBrowserItemsPerRow: 4, + searchMediaBrowserItemsHideTitle: false, + searchMediaBrowserItemsHideSubTitle: true, + searchMediaBrowserItemsSortTitle: false, + searchMediaBrowserSearchLimit: 50, + + showFavBrowserTitle: "Show Favorites for {player.sp_user_display_name} ({medialist.itemcount} items)", + showFavBrowserSubTitle: "click a tile item to play the content; click-hold for actions", + showFavBrowserItemsPerRow: 4, + showFavBrowserItemsHideTitle: false, + showFavBrowserItemsHideSubTitle: true, + showFavBrowserItemsSortTitle: true, + + trackFavBrowserTitle: "Track Favorites for {player.sp_user_display_name} ({medialist.itemcount} items)", + trackFavBrowserSubTitle: "click a tile item to play the content; click-hold for actions", + trackFavBrowserItemsPerRow: 4, + trackFavBrowserItemsHideTitle: false, + trackFavBrowserItemsHideSubTitle: false, + trackFavBrowserItemsSortTitle: false, + + userPresetBrowserTitle: "User Presets for {player.sp_user_display_name} ({medialist.itemcount} items)", + userPresetBrowserSubTitle: "click a tile item to play the content; click-hold for actions", + userPresetBrowserItemsPerRow: 4, + userPresetBrowserItemsHideTitle: false, + userPresetBrowserItemsHideSubTitle: false, + + userPresets: [ + { + "name": "Spotify Playlist 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" + } + ], + + customImageUrls: { + "X_default": "/local/images/spotifyplus_card_customimages/default.png", + "X_empty preset": "/local/images/spotifyplus_card_customimages/empty_preset.png", + "X_Daily Mix 1": "https://brands.home-assistant.io/spotifyplus/icon.png", + "X_playerOffBackground": "/local/images/spotifyplus_card_customimages/playerOffBackground.png", + "X_playerBackground": "/local/images/spotifyplus_card_customimages/playerBackground.png", + } + } + } + + + /** + * Style the element. + */ + private styleCard() { + + let cardWidth: string | undefined = undefined; + let cardHeight: string | undefined = undefined; + let editTabHeight = '0px'; + let editBottomToolbarHeight = '0px'; + + // are we previewing the card in the card editor? + // if so, then we will ignore the configuration dimensions and use constants. + if (isCardInEditPreview(this)) { + + // card is in edit preview. + cardHeight = CARD_EDIT_PREVIEW_HEIGHT; + cardWidth = CARD_EDIT_PREVIEW_WIDTH; + return styleMap({ + '--spc-card-edit-tab-height': `${editTabHeight}`, + '--spc-card-edit-bottom-toolbar-height': `${editBottomToolbarHeight}`, + height: `${cardHeight ? cardHeight : undefined}`, + width: `${cardWidth ? cardWidth : undefined}`, + 'background-repeat': `${!this.playerId ? 'no-repeat' : undefined}`, + 'background-position': `${!this.playerId ? 'center' : undefined}`, + 'background-image': `${!this.playerId ? 'url(' + BRAND_LOGO_IMAGE_BASE64 + ')' : undefined}`, + 'background-size': `${!this.playerId ? BRAND_LOGO_IMAGE_SIZE : undefined}`, + }); + + } + + // set card picker options. + if (isCardInPickerPreview(this)) { + + // card is in pick preview. + cardHeight = CARD_PICK_PREVIEW_HEIGHT; + cardWidth = CARD_PICK_PREVIEW_WIDTH; + return styleMap({ + '--spc-card-edit-tab-height': `${editTabHeight}`, + '--spc-card-edit-bottom-toolbar-height': `${editBottomToolbarHeight}`, + height: `${cardHeight ? cardHeight : undefined}`, + width: `${cardWidth ? cardWidth : undefined}`, + 'background-repeat': `no-repeat`, + 'background-position': `center`, + 'background-image': `url(${BRAND_LOGO_IMAGE_BASE64})`, + 'background-size': `${BRAND_LOGO_IMAGE_SIZE}`, + }); + + } + + // set card editor options. + // we have to account for various editor toolbars in the height calculations when using 'fill' mode. + // we do not have to worry about width calculations, as the width is the same with or without edit mode. + if (isCardInDashboardEditor()) { + + // dashboard is in edit mode. + editTabHeight = EDIT_TAB_HEIGHT; + editBottomToolbarHeight = EDIT_BOTTOM_TOOLBAR_HEIGHT; + + } + + // set card width based on configuration. + // - if 'fill', then use 100% of the horizontal space. + // - if number value specified, then use as width (in rem units). + // - if no value specified, then use default. + if (this.config.width == 'fill') { + cardWidth = '100%'; + } else if (isNumber(String(this.config.width))) { + cardWidth = String(this.config.width) + 'rem'; + } else { + cardWidth = CARD_DEFAULT_WIDTH; + } + + // set card height based on configuration. + // - if 'fill', then use 100% of the vertical space. + // - if number value specified, then use as height (in rem units). + // - if no value specified, then use default. + if (this.config.height == 'fill') { + cardHeight = 'calc(100vh - var(--spc-card-footer-height) - var(--spc-card-edit-tab-height) - var(--spc-card-edit-bottom-toolbar-height))'; + } else if (isNumber(String(this.config.height))) { + cardHeight = String(this.config.height) + 'rem'; + } else { + cardHeight = CARD_DEFAULT_HEIGHT; + } + + //console.log("styleCard (card) - calculated dimensions:\n- cardWidth=%s\n- cardHeight=%s\n- editTabHeight=%s\n- editBottomToolbarHeight=%s", + // cardWidth, + // cardHeight, + // editTabHeight, + // editBottomToolbarHeight, + //); + + return styleMap({ + '--spc-card-edit-tab-height': `${editTabHeight}`, + '--spc-card-edit-bottom-toolbar-height': `${editBottomToolbarHeight}`, + height: `${cardHeight ? cardHeight : undefined}`, + width: `${cardWidth ? cardWidth : undefined}`, + }); + } + + + /** + * Style the element. + */ + private styleCardFooter() { + + // is player selected, and a footer background color set? + if ((this.section == Section.PLAYER) && (this.footerBackgroundColor)) { + + // yes - return vibrant background style. + return styleMap({ + '--spc-player-footer-bg-color': `${this.footerBackgroundColor || 'transparent'}`, + 'background-color': 'var(--spc-player-footer-bg-color)', + 'background-image': 'linear-gradient(rgba(0, 0, 0, 1.6), rgba(0, 0, 0, 0.6))', + }); + + } else { + + // no - just return an empty style to let it default to the card background. + return styleMap({ + }); + + } + } + +} diff --git a/src/components/album-actions.ts b/src/components/album-actions.ts new file mode 100644 index 0000000..eccf13c --- /dev/null +++ b/src/components/album-actions.ts @@ -0,0 +1,434 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { + mdiAccountMusic, + mdiAlbum, + mdiHeart, + mdiHeartOutline, + mdiPlay, +} from '@mdi/js'; + +// our imports. +import { sharedStylesGrid } from '../styles/shared-styles-grid.js'; +import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.js'; +import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; +import { FavActionsBase } from './fav-actions-base'; +import { Section } from '../types/section'; +import { MediaPlayer } from '../model/media-player'; +import { getIdFromSpotifyUri } from '../services/spotifyplus-service'; +import { formatDateHHMMSSFromMilliseconds } from '../utils/utils'; +import { IAlbum } from '../types/spotifyplus/album'; +import { ITrackPageSimplified } from '../types/spotifyplus/track-page-simplified'; +import { GetCopyrights } from '../types/spotifyplus/copyright'; +import { openWindowNewTab } from '../utils/media-browser-utils'; + +/** + * Album actions. + */ +enum Actions { + AlbumFavoriteAdd = "AlbumFavoriteAdd", + AlbumFavoriteRemove = "AlbumFavoriteRemove", + AlbumFavoriteUpdate = "AlbumFavoriteUpdate", + AlbumTracksUpdate = "AlbumTracksUpdate", + ArtistFavoriteAdd = "ArtistFavoriteAdd", + ArtistFavoriteRemove = "ArtistFavoriteRemove", + ArtistFavoriteUpdate = "ArtistFavoriteUpdate", +} + + +class AlbumActions extends FavActionsBase { + + // public state properties. + @property({ attribute: false }) mediaItem!: IAlbum; + + // private state properties. + @state() private albumTracks?: ITrackPageSimplified; + @state() private isAlbumFavorite?: boolean; + @state() private isArtistFavorite?: boolean; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.ALBUM_FAVORITES); + + } + + + /** + * 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(); + + // define actions. + const actionArtistFavoriteAdd = html` +
+ this.onClickAction(Actions.ArtistFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionArtistFavoriteRemove = html` +
+ this.onClickAction(Actions.ArtistFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + const actionAlbumFavoriteAdd = html` +
+ this.onClickAction(Actions.AlbumFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionAlbumFavoriteRemove = html` +
+ this.onClickAction(Actions.AlbumFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + // define supporting icons. + const iconArtist = html` +
+ openWindowNewTab(this.mediaItem.artists[0].external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + const iconAlbum = html` +
+ openWindowNewTab(this.mediaItem.external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + // render html. + // mediaItem will be an IAlbum object when displaying favorites. + // mediaItem will be an IAlbumSimplified object when displaying search results, + // and the label and copyright attributes will not exist. + return html` +
+ ${this.alertError ? html`${this.alertError}` : ""} +
+
+
+
+ ${iconAlbum} + ${this.mediaItem.name} + ${(this.isAlbumFavorite ? actionAlbumFavoriteRemove : actionAlbumFavoriteAdd)} +
+
+ ${iconArtist} + ${this.mediaItem.artists[0].name} + ${(this.isArtistFavorite ? actionArtistFavoriteRemove : actionArtistFavoriteAdd)} +
+
+
Released
+
${this.mediaItem.release_date}
+ +
# Tracks
+
${this.mediaItem.total_tracks}
+ + ${("label" in this.mediaItem) ? html` +
Label
+
${this.mediaItem.label}
+ ` : ""} + + ${("copyrights" in this.mediaItem) ? html` +
Copyright
+
${GetCopyrights(this.mediaItem, "; ")}
+ ` : ""} +
+
+
+
+
+
 
+
#
+
Title
+
Artist
+
Duration
+ ${this.albumTracks?.items.map((item) => html` + this.onClickMediaItem(item)} + slot="icon-button" + >  +
${item.track_number}
+
${item.name}
+
${item.artists[0].name}
+
${formatDateHHMMSSFromMilliseconds(item.duration_ms)}
+ `)} +
+
+
`; + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return [ + sharedStylesGrid, + sharedStylesMediaInfo, + sharedStylesFavActions, + css` + + .album-info-grid { + grid-template-columns: auto auto; + justify-content: left; + } + + .album-actions-container { + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + } + + /* style tracks container and grid */ + .tracks-grid { + grid-template-columns: 40px 30px auto auto 60px; + } + + /* style ha-icon-button controls in tracks grid: icon size, title text */ + .tracks-grid > ha-icon-button[slot="icon-button"] { + --mdc-icon-button-size: 24px; + --mdc-icon-size: 20px; + vertical-align: top; + padding: 0px; + } + + ` + ]; + } + + + /** + * 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; + } + + // show progress indicator. + this.progressShow(); + + // get Spotify Id values for currently selected content. + const uriIdArtist = getIdFromSpotifyUri(this.mediaItem.artists[0].uri); + + // call service based on requested action, and refresh affected action component. + if (action == Actions.AlbumFavoriteAdd) { + + await this.spotifyPlusService.SaveAlbumFavorites(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.AlbumFavoriteUpdate]); + + } else if (action == Actions.AlbumFavoriteRemove) { + + await this.spotifyPlusService.RemoveAlbumFavorites(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.AlbumFavoriteUpdate]); + + } else if (action == Actions.ArtistFavoriteAdd) { + + await this.spotifyPlusService.FollowArtists(this.player.id, uriIdArtist); + this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); + + } else if (action == Actions.ArtistFavoriteRemove) { + + await this.spotifyPlusService.UnfollowArtists(this.player.id, uriIdArtist); + this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); + + } else { + + // no action selected - hide progress indicator. + this.progressHide(); + + } + + return true; + } + + + /** + * 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.AlbumTracksUpdate) != -1) || (updateActions.length == 0)) { + + // create promise - get action list data. + const promiseGetPlaylistItems = new Promise((resolve, reject) => { + + const limit_total = this.mediaItem.total_tracks; + + // call service to retrieve album tracks. + this.spotifyPlusService.GetAlbumTracks(player.id, this.mediaItem.id, 0, 0, null, limit_total) + .then(tracks => { + + // stash the result into state, and resolve the promise. + this.albumTracks = tracks; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.albumTracks = undefined; + this.alertErrorSet("Get Album Tracks failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetPlaylistItems); + } + + // was this action chosen to be updated? + if ((updateActions.indexOf(Actions.AlbumFavoriteUpdate) != -1) || (updateActions.length == 0)) { + + // create promise - check favorite status. + const promiseCheckAlbumFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckAlbumFavorites(player.id, this.mediaItem.id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isAlbumFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isAlbumFavorite = undefined; + this.alertErrorSet("Check Album Favorite failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckAlbumFavorites); + } + + // was this action chosen to be updated? + if ((updateActions.indexOf(Actions.ArtistFavoriteUpdate) != -1) || (updateActions.length == 0)) { + + // create promise - check favorite status. + const promiseCheckArtistFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckArtistsFollowing(player.id, this.mediaItem.artists[0].id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isArtistFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isArtistFavorite = undefined; + this.alertErrorSet("Check Artist Following failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckArtistFavorites); + } + + // 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("Album actions refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} + +customElements.define('spc-album-actions', AlbumActions); diff --git a/src/components/artist-actions.ts b/src/components/artist-actions.ts new file mode 100644 index 0000000..21f97ff --- /dev/null +++ b/src/components/artist-actions.ts @@ -0,0 +1,340 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { + mdiAccountMusic, + mdiHeart, + mdiHeartOutline, + mdiPlay, +} from '@mdi/js'; + +// our imports. +import { sharedStylesGrid } from '../styles/shared-styles-grid.js'; +import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.js'; +import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; +import { FavActionsBase } from './fav-actions-base'; +import { Section } from '../types/section'; +import { MediaPlayer } from '../model/media-player'; +import { IAlbumPageSimplified } from '../types/spotifyplus/album-page-simplified.js'; +import { IArtist } from '../types/spotifyplus/artist'; +import { openWindowNewTab } from '../utils/media-browser-utils.js'; + +/** + * Artist actions. + */ +enum Actions { + ArtistFavoriteAdd = "ArtistFavoriteAdd", + ArtistFavoriteRemove = "ArtistFavoriteRemove", + ArtistFavoriteUpdate = "ArtistFavoriteUpdate", + ArtistAlbumsUpdate = "ArtistAlbumsUpdate", +} + + +class ArtistActions extends FavActionsBase { + + // public state properties. + @property({ attribute: false }) mediaItem!: IArtist; + + // private state properties. + @state() private artistAlbums?: IAlbumPageSimplified; + @state() private isArtistFavorite?: boolean; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.ARTIST_FAVORITES); + + } + + + /** + * 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(); + + // define actions. + const actionArtistFavoriteAdd = html` +
+ this.onClickAction(Actions.ArtistFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionArtistFavoriteRemove = html` +
+ this.onClickAction(Actions.ArtistFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + // define supporting icons. + const iconArtist = html` +
+ openWindowNewTab(this.mediaItem.external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + // render html. + return html` +
+ ${this.alertError ? html`${this.alertError}` : ""} +
+
+
+
+ ${iconArtist} + ${this.mediaItem.name} + ${(this.isArtistFavorite ? actionArtistFavoriteRemove : actionArtistFavoriteAdd)} +
+
+ ${this.mediaItem.followers ? html` +
# Followers
+
${this.mediaItem.followers.total || 0}
+ ` : html`
`} +
+ ${this.mediaItem.genres.length > 0 ? html` +
Genres
+
${this.mediaItem.genres}
+ ` : html`
`} +
+
+
+
+
+
 
+
#
+
Title
+
Released
+
Type
+ ${this.artistAlbums?.items.map((item, index) => html` + this.onClickMediaItem(item)} + slot="icon-button" + >  +
${index + 1}
+
${item.name}
+
${item.release_date}
+
${item.album_type}
+ `)} +
+
+
`; + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return [ + sharedStylesGrid, + sharedStylesMediaInfo, + sharedStylesFavActions, + css` + + .artist-info-grid { + grid-template-columns: auto 10px auto; + justify-content: left; + } + + .artist-actions-container { + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + } + + /* style albums container and grid */ + .albums-grid { + grid-template-columns: 40px 30px auto auto auto; + } + + /* style ha-icon-button controls in tracks grid: icon size, title text */ + .albums-grid > ha-icon-button[slot="icon-button"] { + --mdc-icon-button-size: 24px; + --mdc-icon-size: 20px; + vertical-align: top; + padding: 0px; + } + + ` + ]; + } + + + /** + * 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; + } + + // show progress indicator. + this.progressShow(); + + // call service based on requested action, and refresh affected action component. + if (action == Actions.ArtistFavoriteAdd) { + + await this.spotifyPlusService.FollowArtists(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); + + } else if (action == Actions.ArtistFavoriteRemove) { + + await this.spotifyPlusService.UnfollowArtists(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); + + } else { + + // no action selected - hide progress indicator. + this.progressHide(); + + } + + return true; + } + + + /** + * 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.ArtistAlbumsUpdate) != -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; + + // 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 => { + + // stash the result into state, and resolve the promise. + this.artistAlbums = albums; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.artistAlbums = undefined; + this.alertErrorSet("Get Artist Albums failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetArtistAlbums); + } + + // was this action chosen to be updated? + if ((updateActions.indexOf(Actions.ArtistFavoriteUpdate) != -1) || (updateActions.length == 0)) { + + // create promise - check favorite status. + const promiseCheckArtistFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckArtistsFollowing(player.id, this.mediaItem.id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isArtistFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isArtistFavorite = undefined; + this.alertErrorSet("Check Artist Favorites failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckArtistFavorites); + } + + // 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("Artist actions refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} + +customElements.define('spc-artist-actions', ArtistActions); diff --git a/src/components/audiobook-actions.ts b/src/components/audiobook-actions.ts new file mode 100644 index 0000000..034b163 --- /dev/null +++ b/src/components/audiobook-actions.ts @@ -0,0 +1,356 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { + mdiBookOpenVariant, + mdiHeart, + mdiHeartOutline, + mdiPlay, +} from '@mdi/js'; + +// our imports. +import { sharedStylesGrid } from '../styles/shared-styles-grid.js'; +import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.js'; +import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; +import { FavActionsBase } from './fav-actions-base'; +import { Section } from '../types/section'; +import { MediaPlayer } from '../model/media-player'; +import { GetResumeInfo } from '../types/spotifyplus/resume-point'; +import { IAudiobookSimplified, GetAudiobookNarrators, GetAudiobookAuthors } from '../types/spotifyplus/audiobook-simplified'; +import { IChapterPageSimplified } from '../types/spotifyplus/chapter-page-simplified'; +import { GetCopyrights } from '../types/spotifyplus/copyright'; +import { formatDateHHMMSSFromMilliseconds, unescapeHtml } from '../utils/utils'; +import { openWindowNewTab } from '../utils/media-browser-utils'; + +/** + * Audiobook actions. + */ +enum Actions { + AudiobookFavoriteAdd = "AudiobookFavoriteAdd", + AudiobookFavoriteRemove = "AudiobookFavoriteRemove", + AudiobookFavoriteUpdate = "AudiobookFavoriteUpdate", + AudiobookChaptersUpdate = "AudiobookChaptersUpdate", +} + + +class AudiobookActions extends FavActionsBase { + + // public state properties. + @property({ attribute: false }) mediaItem!: IAudiobookSimplified; + + // private state properties. + @state() private audiobookChapters?: IChapterPageSimplified; + @state() private isAudiobookFavorite?: boolean; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.AUDIOBOOK_FAVORITES); + + } + + + /** + * 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(); + + // define actions. + const actionAudiobookFavoriteAdd = html` +
+ this.onClickAction(Actions.AudiobookFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionAudiobookFavoriteRemove = html` +
+ this.onClickAction(Actions.AudiobookFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + // define supporting icons. + const iconAudiobook = html` +
+ openWindowNewTab(this.mediaItem.external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + // render html. + // mediaItem will be an IAudiobook object when displaying favorites. + // mediaItem will be an IAudiobookSimplified object when displaying search results, + // and the copyright attribute will not exist. + return html` +
+ ${this.alertError ? html`${this.alertError}` : ""} +
+
+
+
+ ${iconAudiobook} + ${this.mediaItem.name} + ${(this.isAudiobookFavorite ? actionAudiobookFavoriteRemove : actionAudiobookFavoriteAdd)} +
+
+ +
Authors
+
${GetAudiobookAuthors(this.mediaItem, ", ")}
+ +
Narrators
+
${GetAudiobookNarrators(this.mediaItem, ", ")}
+ +
Publisher
+
${this.mediaItem.publisher || "unknown"}
+ + ${("copyrights" in this.mediaItem) ? html` +
Copyright
+
${GetCopyrights(this.mediaItem, "; ")}
+ ` : ""} + +
Edition
+
${this.mediaItem.edition || "unknown"}
+ +
Released
+
${this.audiobookChapters?.items[0].release_date || "unknown"}
+ +
+
+
+
+
+
+
 
+
Title
+
Status
+
Duration
+ ${this.audiobookChapters?.items.map((item) => html` + this.onClickMediaItem(item)} + slot="icon-button" + >  +
${item.name}
+
${GetResumeInfo(item.resume_point)}
+
${formatDateHHMMSSFromMilliseconds(item.duration_ms || 0)}
+ `)} +
+
+
`; + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return [ + sharedStylesGrid, + sharedStylesMediaInfo, + sharedStylesFavActions, + css` + + .audiobook-info-grid { + grid-template-columns: auto auto; + justify-content: left; + } + + .audiobook-actions-container { + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + } + + /* style chapters container and grid */ + .chapters-grid { + grid-template-columns: 40px auto auto auto; + margin-top: 1.0rem; + } + + /* style ha-icon-button controls in tracks grid: icon size, title text */ + .chapters-grid > ha-icon-button[slot="icon-button"] { + --mdc-icon-button-size: 24px; + --mdc-icon-size: 20px; + vertical-align: top; + padding: 0px; + } + + ` + ]; + } + + + /** + * 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; + } + + // show progress indicator. + this.progressShow(); + + // call service based on requested action, and refresh affected action component. + if (action == Actions.AudiobookFavoriteAdd) { + + await this.spotifyPlusService.SaveAudiobookFavorites(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.AudiobookFavoriteUpdate]); + + } else if (action == Actions.AudiobookFavoriteRemove) { + + await this.spotifyPlusService.RemoveAudiobookFavorites(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.AudiobookFavoriteUpdate]); + + } else { + + // no action selected - hide progress indicator. + this.progressHide(); + + } + + return true; + } + + + /** + * 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.AudiobookChaptersUpdate) != -1) || (updateActions.length == 0)) { + + // create promise - get action list data. + const promiseGetAudiobookChapters = new Promise((resolve, reject) => { + + const market = null; + const limit_total = 200; + + // call service to retrieve audiobook chapters. + this.spotifyPlusService.GetAudiobookChapters(player.id, this.mediaItem.id, 0, 0, market, limit_total) + .then(chapters => { + + // stash the result into state, and resolve the promise. + this.audiobookChapters = chapters; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.audiobookChapters = undefined; + this.alertErrorSet("Get Audiobook Chapters failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetAudiobookChapters); + } + + // was this action chosen to be updated? + if ((updateActions.indexOf(Actions.AudiobookFavoriteUpdate) != -1) || (updateActions.length == 0)) { + + // create promise - check favorite status. + const promiseCheckAudiobookFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckAudiobookFavorites(player.id, this.mediaItem.id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isAudiobookFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isAudiobookFavorite = undefined; + this.alertErrorSet("Check Audiobook Favorites failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckAudiobookFavorites); + } + + // 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("Audiobook actions refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} + +customElements.define('spc-audiobook-actions', AudiobookActions); diff --git a/src/components/device-actions.ts b/src/components/device-actions.ts new file mode 100644 index 0000000..818fdc6 --- /dev/null +++ b/src/components/device-actions.ts @@ -0,0 +1,148 @@ +// lovelace card imports. +import { css, html, LitElement, PropertyValues, TemplateResult } from 'lit'; +import { property, state } 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 { ISpotifyConnectDevice } from '../types/spotifyplus/spotify-connect-device'; + + +class DeviceActions extends LitElement { + + // public state properties. + @property({ attribute: false }) store!: Store; + @property({ attribute: false }) mediaItem!: ISpotifyConnectDevice; + + // private state properties. + @state() private _alertError?: string; + + + /** + * Invoked on each update to perform rendering tasks. + * This method may return any value renderable by lit-html's `ChildPart` (typically a `TemplateResult`). + * Setting properties inside this method will *not* trigger the element to update. + */ + protected render(): TemplateResult | void { + + // render html. + return html` +
+ ${this._alertError ? html`${this._alertError}` : ""} +
+
+
+
${this.mediaItem.Name}
+
${this.mediaItem.DeviceInfo.BrandDisplayName}
+
${this.mediaItem.DeviceInfo.ModelDisplayName}
+
Product ID: ${this.mediaItem.DeviceInfo.ProductId}
+
+
+
+
+ +
Device ID
+
${this.mediaItem.DeviceInfo.DeviceId}
+ +
Device Name
+
${this.mediaItem.DiscoveryResult.DeviceName}
+ +
Device Type
+
${this.mediaItem.DeviceInfo.DeviceType}
+ +
IP DNS Alias
+
${this.mediaItem.DiscoveryResult.Server}
+ +
IP Address
+
${this.mediaItem.DiscoveryResult.HostIpAddress}
+ +
Zeroconf IP Port
+
${this.mediaItem.DiscoveryResult.HostIpPort}
+ +
Zeroconf CPath
+
${this.mediaItem.DiscoveryResult.SpotifyConnectCPath}
+ +
Is Dynamic Device?
+
${this.mediaItem.DiscoveryResult.IsDynamicDevice}
+ +
Is in Device List?
+
${this.mediaItem.DeviceInfo.IsInDeviceList}
+ +
Auth Token Type
+
${this.mediaItem.DeviceInfo.TokenType}
+ +
Client ID
+
${this.mediaItem.DeviceInfo.ClientId}
+ +
Library Version
+
${this.mediaItem.DeviceInfo.LibraryVersion}
+ +
Group Status
+
${this.mediaItem.DeviceInfo.GroupStatus}
+ +
Voice Support?
+
${this.mediaItem.DeviceInfo.VoiceSupport}
+ +
+
+
`; + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return [ + sharedStylesGrid, + sharedStylesMediaInfo, + css` + + .device-actions-container { + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + } + + .device-grid { + grid-template-columns: auto auto; + justify-content: left; + } + + /* style ha-alert controls */ + ha-alert { + display: block; + margin-bottom: 0.25rem; + } + + ` + ]; + } + + + /** + * 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; + } + +} + + +customElements.define('spc-device-actions', DeviceActions); \ No newline at end of file diff --git a/src/components/episode-actions.ts b/src/components/episode-actions.ts new file mode 100644 index 0000000..30e5c9c --- /dev/null +++ b/src/components/episode-actions.ts @@ -0,0 +1,431 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { + mdiPodcast, + mdiHeart, + mdiHeartOutline, + mdiMicrophone, +} from '@mdi/js'; + +// our imports. +import { sharedStylesGrid } from '../styles/shared-styles-grid.js'; +import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.js'; +import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; +import { 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'; +import { IEpisode, isEpisodeObject } from '../types/spotifyplus/episode'; +import { IEpisodeSimplified } from '../types/spotifyplus/episode-simplified'; + +/** + * Episode actions. + */ +enum Actions { + EpisodeFavoriteAdd = "EpisodeFavoriteAdd", + EpisodeFavoriteRemove = "EpisodeFavoriteRemove", + EpisodeFavoriteUpdate = "EpisodeFavoriteUpdate", + EpisodeUpdate = "EpisodeUpdate", + ShowFavoriteAdd = "ShowFavoriteAdd", + ShowFavoriteRemove = "ShowFavoriteRemove", + ShowFavoriteUpdate = "ShowFavoriteUpdate", +} + + +class EpisodeActions extends FavActionsBase { + + // public state properties. + @property({ attribute: false }) mediaItem!: IEpisodeSimplified | IEpisode; + + // private state properties. + @state() private isEpisodeFavorite?: boolean; + @state() private isShowFavorite?: boolean; + @state() private episode?: IEpisode; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.EPISODE_FAVORITES); + + } + + + /** + * 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(); + + // define actions. + const actionShowFavoriteAdd = html` +
+ this.onClickAction(Actions.ShowFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionShowFavoriteRemove = html` +
+ this.onClickAction(Actions.ShowFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + const actionEpisodeFavoriteAdd = html` +
+ this.onClickAction(Actions.EpisodeFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionEpisodeFavoriteRemove = html` +
+ this.onClickAction(Actions.EpisodeFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + // define supporting icons. + const iconShow = html` +
+ openWindowNewTab(this.episode?.show.external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + const iconEpisode = html` +
+ openWindowNewTab(this.episode?.external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + // render html. + // note that mediaItem could be an IEpisode or IEpisodeSimplified object. + return html` +
+ ${this.alertError ? html`${this.alertError}` : ""} +
+
+
+
+ ${iconEpisode} + ${this.episode?.name} + ${(this.isEpisodeFavorite ? actionEpisodeFavoriteRemove : actionEpisodeFavoriteAdd)} +
+
+ ${iconShow} + ${this.episode?.show.name} + ${(this.isShowFavorite ? actionShowFavoriteRemove : actionShowFavoriteAdd)} +
+
+
Duration
+
${formatDateHHMMSSFromMilliseconds(this.episode?.duration_ms || 0)}
+
 
+
Released
+
${this.episode?.release_date}
+ +
Explicit
+
${this.episode?.explicit || false}
+
 
+
Links
+
+ openWindowNewTab(this.episode?.show.external_urls.spotify || "")} + slot="media-info-icon-link-s" + > + openWindowNewTab(this.episode?.external_urls.spotify || "")} + slot="media-info-icon-link-s" + > +
+ +
+
+
+
+
+
+
+ `; + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return [ + sharedStylesGrid, + sharedStylesMediaInfo, + sharedStylesFavActions, + css` + + .episode-info-grid { + grid-template-columns: auto auto 30px auto auto; + justify-content: left; + } + + .episode-actions-container { + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + } + ` + ]; + } + + + /** + * 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; + } + + // show progress indicator. + this.progressShow(); + + // call service based on requested action, and refresh affected action component. + if (action == Actions.ShowFavoriteAdd) { + + await this.spotifyPlusService.SaveShowFavorites(this.player.id, this.episode?.show.id); + this.updateActions(this.player, [Actions.ShowFavoriteUpdate]); + + } else if (action == Actions.ShowFavoriteRemove) { + + await this.spotifyPlusService.RemoveShowFavorites(this.player.id, this.episode?.show.id); + this.updateActions(this.player, [Actions.ShowFavoriteUpdate]); + + } else if (action == Actions.EpisodeFavoriteAdd) { + + await this.spotifyPlusService.SaveEpisodeFavorites(this.player.id, this.episode?.id); + this.updateActions(this.player, [Actions.EpisodeFavoriteUpdate]); + + } else if (action == Actions.EpisodeFavoriteRemove) { + + await this.spotifyPlusService.RemoveEpisodeFavorites(this.player.id, this.episode?.id); + this.updateActions(this.player, [Actions.EpisodeFavoriteUpdate]); + + } else { + + // no action selected - hide progress indicator. + this.progressHide(); + + } + + return true; + } + + + /** + * 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.EpisodeUpdate) != -1) || (updateActions.length == 0)) { + + // reset favorite indicators. + this.isEpisodeFavorite = undefined; + this.isShowFavorite = undefined; + + // determine if the mediaItem implements the IEpisode interface. + // if it's IEpisode, then just set the episode reference from mediaItem. + // if it's IEpisodeSimplified, then we need to get the full IEpisode. + if (isEpisodeObject(this.mediaItem)) { + + // set the episode reference from mediaItem. + this.episode = this.mediaItem; + + // indicate we are no longer updating. + this.isUpdateInProgress = false; + + // update the favorite settings. + setTimeout(() => { + this.updateActions(player, [Actions.EpisodeFavoriteUpdate, Actions.ShowFavoriteUpdate]); + }, 50); + + return true; + } + + // create promise - update currently selected media item. + const promiseEpisodeUpdate = new Promise((resolve, reject) => { + + // call service to retrieve media item that is currently selected. + this.spotifyPlusService.GetEpisode(player.id, this.mediaItem.id) + .then(result => { + + // load results, update favorites, and resolve the promise. + this.episode = result; + + // update the favorite settings. + setTimeout(() => { + this.updateActions(player, [Actions.EpisodeFavoriteUpdate, Actions.ShowFavoriteUpdate]); + }, 50); + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.episode = undefined; + this.alertErrorSet("Get Episode call failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseEpisodeUpdate); + } + + // was this action chosen to be updated? + if (updateActions.indexOf(Actions.ShowFavoriteUpdate) != -1) { + + // create promise - check favorite status. + const promiseCheckShowFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckShowFavorites(player.id, this.episode?.show.id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isShowFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isShowFavorite = undefined; + this.alertErrorSet("Check Show Favorite failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckShowFavorites); + } + + // was this action chosen to be updated? + if (updateActions.indexOf(Actions.EpisodeFavoriteUpdate) != -1) { + + // create promise - check favorite status. + const promiseCheckEpisodeFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckEpisodeFavorites(player.id, this.episode?.id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isEpisodeFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isEpisodeFavorite = undefined; + this.alertErrorSet("Check Episode Favorites failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckEpisodeFavorites); + } + + // 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("Episode actions refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} + +customElements.define('spc-episode-actions', EpisodeActions); diff --git a/src/components/fav-actions-base.ts b/src/components/fav-actions-base.ts new file mode 100644 index 0000000..38bd3fc --- /dev/null +++ b/src/components/fav-actions-base.ts @@ -0,0 +1,281 @@ +// lovelace card imports. +import { LitElement, PropertyValues, TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +// our imports. +import { Store } from '../model/store'; +import { Section } from '../types/section'; +import { MediaPlayer } from '../model/media-player'; +import { SpotifyPlusService } from '../services/spotifyplus-service'; +import { isCardInEditPreview } from '../utils/utils'; +import { ProgressStartedEvent } from '../events/progress-started'; +import { ProgressEndedEvent } from '../events/progress-ended'; + +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from '../constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":fav-actions-base"); + + +export class FavActionsBase extends LitElement { + + // public state properties. + @property({ attribute: false }) protected store!: Store; + @property({ attribute: false }) protected mediaItem!: any; + + // private state properties. + @state() protected alertError?: string; + + /** MediaPlayer instance created from the configuration entity id. */ + protected player!: MediaPlayer; + + /** SpotifyPlus services instance. */ + protected spotifyPlusService!: SpotifyPlusService; + + /** Type of media being accessed. */ + protected section!: Section; + + /** Indicates if actions are currently being updated. */ + protected isUpdateInProgress!: boolean; + + /** True if the card is in edit preview mode (e.g. being edited); otherwise, false. */ + protected isCardInEditPreview!: boolean; + + + /** + * Initializes a new instance of the class. + * + * @param section Section that is currently selected. + */ + constructor(section: Section) { + + // invoke base class method. + super(); + + // initialize storage. + this.isUpdateInProgress = false; + this.section = section; + + } + + + + /** + * 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 { + + // set common references from application common storage area. + this.player = this.store.player; + this.spotifyPlusService = this.store.spotifyPlusService; + + // all html is rendered in the inheriting class. + } + + + ///** + // * style definitions used by this component. + // * */ + //static get styles() { + + // return [ + // //sharedStylesFavBrowser, + // css` + // ` + // ]; + //} + + + /** + * Invoked when the component is added to the document's DOM. + * + * In `connectedCallback()` you should setup tasks that should only occur when + * the element is connected to the document. The most common of these is + * adding event listeners to nodes external to the element, like a keydown + * event handler added to the window. + */ + public connectedCallback() { + + // invoke base class method. + super.connectedCallback(); + + // determine if card configuration is being edited. + this.isCardInEditPreview = isCardInEditPreview(this.store.card); + + } + + + /** + * 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); + + // refresh the body actions. + this.updateActions(this.player, []); + } + + + /** + * Clears the error and informational alert text. + */ + protected alertClear() { + this.alertError = undefined; + } + + + /** + * Clears the error alert text. + */ + protected alertErrorClear() { + this.alertError = undefined; + } + + + /** + * Sets the alert error message, and clears the informational alert message. + */ + protected alertErrorSet(message: string): void { + this.alertError = message; + } + + + /** + * Hide visual progress indicator. + */ + protected progressHide(): void { + this.isUpdateInProgress = false; + this.store.card.dispatchEvent(ProgressEndedEvent()); + } + + + /** + * Show visual progress indicator. + */ + protected progressShow(): void { + this.store.card.dispatchEvent(ProgressStartedEvent()); + } + + + /** + * Handles the `click` event fired when a media item control icon is clicked. + * + * @param control Event arguments. + */ + protected onClickMediaItem(mediaItem: any) { + + // play the selected media item. + this.PlayMediaItem(mediaItem); + + } + + + /** + * Calls the SpotifyPlusService Card_PlayMediaBrowserItem method to play media. + * + * @param mediaItem The medialist item that was selected. + */ + protected async PlayMediaItem(mediaItem: any) { + + try { + + // show progress indicator. + this.progressShow(); + + // play media item. + await this.spotifyPlusService.Card_PlayMediaBrowserItem(this.player, mediaItem); + + // show player section. + this.store.card.SetSection(Section.PLAYER); + + } + catch (error) { + + // set error status, + this.alertErrorSet("Could not play media item. " + (error as Error).message); + + } + finally { + + // hide progress indicator. + this.progressHide(); + + } + + } + + + /** + * Handles the `click` event fired when a control icon is clicked. + * This method should be overridden by the inheriting class. + * + * @param action Action to execute. + */ + protected async onClickAction(action: any): Promise { + + throw new Error("onClickAction not implemented for action \"" + action + "\"."); + + } + + + /** + * 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 updateActions( + player: MediaPlayer, + _updateActions: any[], + ): boolean { + + if (debuglog.enabled) { + debuglog("updateActions - updating actions: %s", + JSON.stringify(Array.from(_updateActions.values())), + ); + } + + // check if update is already in progress. + if (!this.isUpdateInProgress) { + this.isUpdateInProgress = true; + } else { + this.alertErrorSet("Previous refresh is still in progress - please wait"); + return false; + } + + // if card is being edited, then don't bother. + if (this.isCardInEditPreview) { + this.isUpdateInProgress = false; + return false; + } + + // if player reference not set then we are done. + if (!player) { + 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) { + this.isUpdateInProgress = false; + this.alertErrorSet("MediaItem not set in updateActions"); + return false; + } + + // clear alerts. + this.alertClear(); + + // indicate caller can refresh it's actions. + return true; + + } + +} diff --git a/src/components/footer.ts b/src/components/footer.ts new file mode 100644 index 0000000..6fd4ff7 --- /dev/null +++ b/src/components/footer.ts @@ -0,0 +1,186 @@ +// lovelace card imports. +import { css, html, LitElement, TemplateResult, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { + mdiAccountMusic, + mdiAlbum, + mdiBookmarkMusicOutline, + mdiBookOpenVariant, + mdiHistory, + mdiMicrophone, + mdiMusic, + mdiPlayCircle, + mdiPlaylistPlay, + mdiPodcast, + mdiSearchWeb, + mdiSpeaker, +} from '@mdi/js'; + +// our imports. +import { SHOW_SECTION } from '../constants'; +import { CardConfig } from '../types/card-config'; +import { Section } from '../types/section'; +import { customEvent } from '../utils/utils'; + + +@customElement("spc-footer") +export class Footer extends LitElement { + + @property({ attribute: false }) config!: CardConfig; + @property() section!: Section; + + /** + * 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 { + + return html` + this.OnSectionClick(Section.PLAYER)} + selected=${this.getSectionSelected(Section.PLAYER)} + hide=${this.getSectionEnabled(Section.PLAYER)} + > + this.OnSectionClick(Section.DEVICES)} + selected=${this.getSectionSelected(Section.DEVICES)} + hide=${this.getSectionEnabled(Section.DEVICES)} + > + this.OnSectionClick(Section.USERPRESETS)} + selected=${this.getSectionSelected(Section.USERPRESETS)} + hide=${this.getSectionEnabled(Section.USERPRESETS)} + > + this.OnSectionClick(Section.RECENTS)} + selected=${this.getSectionSelected(Section.RECENTS)} + hide=${this.getSectionEnabled(Section.RECENTS)} + > + this.OnSectionClick(Section.PLAYLIST_FAVORITES)} + selected=${this.getSectionSelected(Section.PLAYLIST_FAVORITES)} + hide=${this.getSectionEnabled(Section.PLAYLIST_FAVORITES)} + > + this.OnSectionClick(Section.ALBUM_FAVORITES)} + selected=${this.getSectionSelected(Section.ALBUM_FAVORITES)} + hide=${this.getSectionEnabled(Section.ALBUM_FAVORITES)} + > + this.OnSectionClick(Section.ARTIST_FAVORITES)} + selected=${this.getSectionSelected(Section.ARTIST_FAVORITES)} + hide=${this.getSectionEnabled(Section.ARTIST_FAVORITES)} + > + this.OnSectionClick(Section.TRACK_FAVORITES)} + selected=${this.getSectionSelected(Section.TRACK_FAVORITES)} + hide=${this.getSectionEnabled(Section.TRACK_FAVORITES)} + > + this.OnSectionClick(Section.AUDIOBOOK_FAVORITES)} + selected=${this.getSectionSelected(Section.AUDIOBOOK_FAVORITES)} + hide=${this.getSectionEnabled(Section.AUDIOBOOK_FAVORITES)} + > + this.OnSectionClick(Section.SHOW_FAVORITES)} + selected=${this.getSectionSelected(Section.SHOW_FAVORITES)} + hide=${this.getSectionEnabled(Section.SHOW_FAVORITES)} + > + this.OnSectionClick(Section.EPISODE_FAVORITES)} + selected=${this.getSectionSelected(Section.EPISODE_FAVORITES)} + hide=${this.getSectionEnabled(Section.EPISODE_FAVORITES)} + > + this.OnSectionClick(Section.SEARCH_MEDIA)} + selected=${this.getSectionSelected(Section.SEARCH_MEDIA)} + hide=${this.getSectionEnabled(Section.SEARCH_MEDIA)} + > + `; + } + + + /** + * Style definitions used by this card section. + */ + static get styles() { + return css` + :host > *[selected] { + color: var(--dark-primary-color); + } + + :host > *[hide] { + display: none; + } + + .ha-icon-button { + --mwc-icon-button-size: 3rem; + --mwc-icon-size: 2rem; + } + `; + } + + + /** + * Handles the `click` event fired when a section icon is clicked. + * + * @param section Event arguments. + */ + private OnSectionClick(section: Section) { + + this.dispatchEvent(customEvent(SHOW_SECTION, section)); + + } + + + /** + * Checks to see if a section is active or not, and returns true for the specified + * section if it's the active section. This is what shows the selected section + * in the footer area. + * + * @param section Section identifier to check. + */ + private getSectionSelected(section: Section | typeof nothing) { + + return this.section === section || nothing; + + } + + + /** + * Returns nothing if the specified section value is NOT enabled in the configuration, + * which will cause the section icon to be hidden (via css styling). + * + * @param section Section identifier to check. + */ + private getSectionEnabled(searchElement: Section) { + + return (this.config.sections && !this.config.sections?.includes(searchElement)) || nothing; + + } +} diff --git a/src/components/media-browser-base.ts b/src/components/media-browser-base.ts new file mode 100644 index 0000000..dd8c1f5 --- /dev/null +++ b/src/components/media-browser-base.ts @@ -0,0 +1,444 @@ +// lovelace card imports. +import { css, LitElement, TemplateResult } from 'lit'; +import { eventOptions, property } from 'lit/decorators.js'; + +// our imports. +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 { SearchMediaTypes } from '../types/search-media-types'; + + +export class MediaBrowserBase extends LitElement { + + // public state properties. + @property({ attribute: false }) protected store!: Store; + @property({ attribute: false }) protected items!: IMediaBrowserItem[]; + @property({ attribute: false }) protected mediaType!: any; + + protected config!: CardConfig; + protected section!: Section; + protected mousedownTimestamp!: number; + protected touchstartScrollTop!: number; + + protected hideTitle!: boolean; + protected hideSubTitle!: boolean; + protected isTouchDevice!: boolean; + protected itemsPerRow!: number; + protected listItemClass!: string; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(); + + // initialize storage. + this.mousedownTimestamp = 0; + this.touchstartScrollTop = 0; + + } + + + /** + * 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 { + + // set common references from application common storage area. + this.config = this.store.config; + this.section = this.store.section; + + // set title / source visibility based on selected section. + this.hideTitle = true; + this.hideSubTitle = true; + this.itemsPerRow = 1; + this.listItemClass = 'button'; + + // assign the mediaType based on the section value. + // for search, we will convert the SearchMediaType to a Section type. + if (this.section != Section.SEARCH_MEDIA) { + this.mediaType = this.section; + } else { + if (this.mediaType == SearchMediaTypes.ALBUMS) { + this.mediaType = Section.ALBUM_FAVORITES; + } else if (this.mediaType == SearchMediaTypes.ARTISTS) { + this.mediaType = Section.ARTIST_FAVORITES; + } else if (this.mediaType == SearchMediaTypes.AUDIOBOOKS) { + this.mediaType = Section.AUDIOBOOK_FAVORITES; + } else if (this.mediaType == SearchMediaTypes.EPISODES) { + this.mediaType = Section.EPISODE_FAVORITES; + } else if (this.mediaType == SearchMediaTypes.PLAYLISTS) { + this.mediaType = Section.PLAYLIST_FAVORITES; + } else if (this.mediaType == SearchMediaTypes.SHOWS) { + this.mediaType = Section.SHOW_FAVORITES; + } else if (this.mediaType == SearchMediaTypes.TRACKS) { + this.mediaType = Section.TRACK_FAVORITES; + } + } + + // set item control properties from configuration settings. + if (this.mediaType == Section.ALBUM_FAVORITES) { + this.itemsPerRow = this.config.albumFavBrowserItemsPerRow || 4; + this.hideTitle = this.config.albumFavBrowserItemsHideTitle || false; + this.hideSubTitle = this.config.albumFavBrowserItemsHideSubTitle || false; + } else if (this.mediaType == Section.ARTIST_FAVORITES) { + this.itemsPerRow = this.config.artistFavBrowserItemsPerRow || 4; + this.hideTitle = this.config.artistFavBrowserItemsHideTitle || false; + this.hideSubTitle = this.config.artistFavBrowserItemsHideSubTitle || false; + } else if (this.mediaType == Section.AUDIOBOOK_FAVORITES) { + this.itemsPerRow = this.config.audiobookFavBrowserItemsPerRow || 4; + this.hideTitle = this.config.audiobookFavBrowserItemsHideTitle || false; + this.hideSubTitle = this.config.audiobookFavBrowserItemsHideSubTitle || false; + } else if (this.mediaType == Section.DEVICES) { + this.itemsPerRow = this.config.deviceBrowserItemsPerRow || 1; + 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'; + } else if (this.mediaType == Section.EPISODE_FAVORITES) { + this.itemsPerRow = this.config.episodeFavBrowserItemsPerRow || 4; + this.hideTitle = this.config.episodeFavBrowserItemsHideTitle || false; + this.hideSubTitle = this.config.episodeFavBrowserItemsHideSubTitle || false; + } else if (this.mediaType == Section.PLAYLIST_FAVORITES) { + this.itemsPerRow = this.config.playlistFavBrowserItemsPerRow || 4; + this.hideTitle = this.config.playlistFavBrowserItemsHideTitle || false; + this.hideSubTitle = this.config.playlistFavBrowserItemsHideSubTitle || false; + } else if (this.mediaType == Section.RECENTS) { + this.itemsPerRow = this.config.recentBrowserItemsPerRow || 4; + this.hideTitle = this.config.recentBrowserItemsHideTitle || false; + this.hideSubTitle = this.config.recentBrowserItemsHideSubTitle || false; + } else if (this.mediaType == Section.SHOW_FAVORITES) { + this.itemsPerRow = this.config.showFavBrowserItemsPerRow || 4; + this.hideTitle = this.config.showFavBrowserItemsHideTitle || false; + this.hideSubTitle = this.config.showFavBrowserItemsHideSubTitle || false; + } else if (this.mediaType == Section.TRACK_FAVORITES) { + this.itemsPerRow = this.config.trackFavBrowserItemsPerRow || 4; + this.hideTitle = this.config.trackFavBrowserItemsHideTitle || false; + this.hideSubTitle = this.config.trackFavBrowserItemsHideSubTitle || false; + } else if (this.mediaType == Section.USERPRESETS) { + this.itemsPerRow = this.config.userPresetBrowserItemsPerRow || 4; + this.hideTitle = this.config.userPresetBrowserItemsHideTitle || false; + this.hideSubTitle = this.config.userPresetBrowserItemsHideSubTitle || false; + } + + // if search section was specified AND we are not using media type settings, then + // use search config settings for ItemsPerRow, HideTitle, and HideSubTitle values. + if (this.section == Section.SEARCH_MEDIA) { + if (this.config.searchMediaBrowserUseDisplaySettings || false) { + this.itemsPerRow = this.config.searchMediaBrowserItemsPerRow || 4; + this.hideTitle = this.config.searchMediaBrowserItemsHideTitle || false; + this.hideSubTitle = this.config.searchMediaBrowserItemsHideSubTitle || false; + } + } + + // all html is rendered in the inheriting class. + } + + + /** + * Style definitions used by this card section. + * + * --control-button-padding: 0px; // image with rounded corners + */ + static get styles() { + return [ + styleMediaBrowserItemTitle, + css` + .icons { + display: flex; + flex-wrap: wrap; + } + + .button { + --control-button-padding: 0px; + --margin: 0.6%; + --width: calc(100% / var(--items-per-row) - (var(--margin) * 2)); + width: var(--width); + height: var(--width); + margin: var(--margin); + } + + .thumbnail { + width: 100%; + padding-bottom: 100%; + margin: 0 6%; + background-size: 100%; + background-repeat: no-repeat; + background-position: center; + } + + .title { + font-size: 0.8rem; + position: absolute; + width: 100%; + line-height: 160%; + bottom: 0; + background-color: rgba(var(--rgb-card-background-color), 0.733); + } + + .title-active { + color: var(--dark-primary-color) !important; + } + + .title-source { + font-size: 0.8rem; + width: 100%; + line-height: 160%; + } + `, + ]; + } + + + /** + * Invoked when the component is added to the document's DOM. + * + * In `connectedCallback()` you should setup tasks that should only occur when + * the element is connected to the document. The most common of these is + * adding event listeners to nodes external to the element, like a keydown + * event handler added to the window. + */ + public connectedCallback() { + + // invoke base class method. + super.connectedCallback(); + + // determine if this is a touch device (true) or not (false). + this.isTouchDevice = isTouchDevice(); + + if (this.isTouchDevice) { + // for touch devices, prevent context menu from showing when user ends the touch. + this.addEventListener('touchend', function (e) { + if (e.cancelable) { + e.preventDefault(); + } + }, { passive: false }); + } + } + + + /** + * Event fired when a mouseup event takes place for a media browser + * item button. + * + * This event is NOT fired for touch devices. + * + * @param event Event arguments. + */ + protected onMediaBrowserItemClick(event: CustomEvent): boolean { + + // have we already fired the click event? + if (this.mousedownTimestamp == -1) { + return true; + } + + // calculate the duration of the mouse down / up operation. + // we are looking to determine how long the mouse button was in the down + // position (e.g.the duration). if the duration was greater than 1.0 seconds, + // then we will treat the event as a "click and hold" operation; otherwise, + // we will treat the event as a "click" operation. + const duration = Date.now() - this.mousedownTimestamp; + this.mousedownTimestamp = -1; + if (duration < 1000) { + return this.dispatchEvent(customEvent(ITEM_SELECTED, event.detail)); + } else { + return this.dispatchEvent(customEvent(ITEM_SELECTED_WITH_HOLD, event.detail)); + } + + } + + + /** + * Event fired when a mousedown event takes place for a media browser + * item button. + * + * This event is NOT fired for touch devices. + * + * The `@eventOptions` will prevent the following warning messages in Chrome: + * [Violation] Added non-passive event listener to a scroll-blocking event. + * Consider marking event handler as 'passive' to make the page more responsive. + * This will tell browsers that `preventDefault()` will never be called on those + * events, which will increase performance as well as remove the warning message. + */ + protected onMediaBrowserItemMouseDown(): boolean { + + // store the current time (in milliseconds) so that we can calculate + // the duration in the "click" event (occurs after a mouseup event). + this.mousedownTimestamp = Date.now(); + + // automatically fire the click event in 1100 milliseconds. + // we will handle any duplicate click event in the click event handler. + setTimeout(() => { + this.shadowRoot?.activeElement?.dispatchEvent(new Event('click')); + }, 1100); + + return true; + } + + + /** + * Event fired when a mouseup event takes place for a media browser + * item button. + * + * This event is NOT fired for touch devices. + * + * @param event Event arguments. + */ + protected onMediaBrowserItemMouseUp(event: CustomEvent): boolean { + + // have we already fired the click event? + if (this.mousedownTimestamp == -1) { + return true; + } + + // calculate the duration of the mouse down / up operation. + // we are looking to determine how long the mouse button was in the down + // position (e.g.the duration). if the duration was greater than 1.0 seconds, + // then we will treat the event as a "click and hold" operation; otherwise, + // we will treat the event as a "click" operation. + const duration = Date.now() - this.mousedownTimestamp; + this.mousedownTimestamp = -1; + if (duration < 1000) { + return this.dispatchEvent(customEvent(ITEM_SELECTED, event.detail)); + } else { + return this.dispatchEvent(customEvent(ITEM_SELECTED_WITH_HOLD, event.detail)); + } + + } + + + /** + * Event fired when a touchstart event takes place for a media browser + * item button. + * + * This event is NOT fired for non-touch devices (e.g. mouse). + * + * @param event Event arguments. + * + * The event listener expression `{handleEvent: () => ... , passive: true }` syntax + * is used to set passive to true on the `addEventHandler` definition. Using this + * syntax prevents the `[Violation] Added non-passive event listener to a scroll-blocking + * event. Consider marking event handler as 'passive' to make the page more + * responsive` warnings that are generated by Chrome and other browsers. These + * warnings only seem to appear for the `touchstart`, `touchmove`, and `scroll` + * declarative events. + * + * Note that the `@eventOptions({ passive: true })` has no effect when using a `() =>` + * event expression on the declarative event listener! We left it here though, just + * in case we change the event listener expression in the future. + */ + @eventOptions({ passive: true }) + protected onMediaBrowserItemTouchStart(event: CustomEvent): boolean { + + // store the current time (in milliseconds) so that we can calculate + // the duration in the "click" event (occurs after a mouseup event). + this.mousedownTimestamp = Date.now(); + + // find the parent `mediaBrowserContentElement` and get the scroll position. + // for touch devices, we need to determine if the touchstart / touchend + // events are for scrolling or tap and hold. + const divContainer = closestElement('#mediaBrowserContentElement', this) as HTMLDivElement; + if (divContainer) { + this.touchstartScrollTop = divContainer.scrollTop; + } + + // fire the following logic in 1100 milliseconds (1.1 seconds) that will determine + // if a press-and-hold action took place (versus a press action). + setTimeout(() => { + + // if a press action took place, then we are done. + if (this.mousedownTimestamp == -1) { + return; + } + + // find the parent `mediaBrowserContentElement` and get the current scroll position. + // for touch devices, we need to determine if the user is scrolling or if they + // want to issue a press / press-and-hold action. + // we do this by comparing the scroll position of the parent container when the touch + // was initiated to the current scroll position. + // if original and current scroll positions are not equal (or nearly so), then it's a + // scroll operation and we can ignore the event. + const divContainer = closestElement('#mediaBrowserContentElement', this) as HTMLDivElement; + let scrollTopDifference = 0; + if (divContainer) { + scrollTopDifference = this.touchstartScrollTop - divContainer.scrollTop; + if (scrollTopDifference != 0) { + return; + } + } + + // at this point, we know it's a press-and-hold action. + // dispatch the ITEM_SELECTED_WITH_HOLD event, and reset timestamp to indicate it was handled. + this.mousedownTimestamp = -1; + return this.dispatchEvent(customEvent(ITEM_SELECTED_WITH_HOLD, event.detail)); + + }, 1100); + + return true; + } + + + /** + * Event fired when a touchend event takes place for a media browser + * item button. + * + * This event is NOT fired for non-touch devices (e.g. mouse). + * + * @param event Event arguments. + * + * The `@eventOptions` will prevent the following warning messages in Chrome: + * [Violation] Added non-passive event listener to a scroll-blocking event. + * Consider marking event handler as 'passive' to make the page more responsive. + * This will tell browsers that `preventDefault()` will never be called on those + * events, which will increase performance as well as remove the warning message. + */ + protected onMediaBrowserItemTouchEnd(event: CustomEvent): boolean { + + // have we already fired the click event? + if (this.mousedownTimestamp == -1) { + return true; + } + + // find the parent `mediaBrowserContentElement` and get the scroll position. + // for touch devices, we need to determine if the touchstart / touchend + // events are for scrolling or tap and hold. + const divContainer = closestElement('#mediaBrowserContentElement', this) as HTMLDivElement; + let scrollTopDifference = 0; + if (divContainer) { + scrollTopDifference = this.touchstartScrollTop - divContainer.scrollTop; + + // if scroll positions are not equal (or nearly so), then it's a scroll + // operation and we can ignore the event. + if (scrollTopDifference != 0) { + return true; + } + } + + // calculate the duration of the mouse down / up operation. + // we are looking to determine how long the mouse button was in the down + // position (e.g.the duration). if the duration was greater than 1.0 seconds, + // then we will treat the event as a "click and hold" operation; otherwise, + // we will treat the event as a "click" operation. + const duration = Date.now() - this.mousedownTimestamp; + this.mousedownTimestamp = -1; + if (duration < 1000) { + return this.dispatchEvent(customEvent(ITEM_SELECTED, event.detail)); + } else { + return this.dispatchEvent(customEvent(ITEM_SELECTED_WITH_HOLD, event.detail)); + } + + } + +} diff --git a/src/components/media-browser-icons.ts b/src/components/media-browser-icons.ts new file mode 100644 index 0000000..91c34c2 --- /dev/null +++ b/src/components/media-browser-icons.ts @@ -0,0 +1,161 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +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 { + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(); + } + + + /** + * 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(); + + // 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)} + ${(() => { + if (this.isTouchDevice) { + return (html` + 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)} + + `); + } 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)} + + `); + } + })()} + `, + )} +
+ `; + } + + //${(() => { + // 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. + * + * --control-button-padding: 0px; // image with rounded corners + */ + static get styles() { + return [ + styleMediaBrowserItemTitle, + css` + .icons { + display: flex; + flex-wrap: wrap; + } + + .button { + --control-button-padding: 0px; + --margin: 0.6%; + --width: calc(100% / var(--items-per-row) - (var(--margin) * 2)); + width: var(--width); + height: var(--width); + margin: var(--margin); + } + + .thumbnail { + width: 100%; + padding-bottom: 100%; + margin: 0 6%; + background-size: 100%; + background-repeat: no-repeat; + background-position: center; + } + + .title { + font-size: 0.8rem; + position: absolute; + width: 100%; + line-height: 160%; + bottom: 0; + background-color: rgba(var(--rgb-card-background-color), 0.733); + } + + .title-source { + font-size: 0.8rem; + width: 100%; + line-height: 160%; + } + `, + ]; + } +} + +customElements.define('spc-media-browser-icons', MediaBrowserIcons); diff --git a/src/components/media-browser-list.ts b/src/components/media-browser-list.ts new file mode 100644 index 0000000..b39525f --- /dev/null +++ b/src/components/media-browser-list.ts @@ -0,0 +1,192 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { when } from 'lit/directives/when.js'; + +// our imports. +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 { + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(); + } + + + /** + * 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(); + + // define control to render - search criteria. + const nowPlayingBars = html` +
+
+
+
+
+
+ `; + + // 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)} + ${(() => { + if (this.isTouchDevice) { + return (html` + 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)}
+ ${when( + item.mbi_item.is_active && this.store.player.isPlaying() && this.section == Section.DEVICES, + () => html`${nowPlayingBars}`, + )} +
+ `); + } 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)}
+ ${when( + item.mbi_item.is_active && this.store.player.isPlaying() && this.section == Section.DEVICES, + () => html`${nowPlayingBars}`, + )} +
+ `); + } + })()} + `; + })} +
+ `; + } + + + /** + * Style definitions used by this card section. + * + * --control-button-padding: 0px; // image with rounded corners + */ + static get styles() { + return [ + css` + .button { + --control-button-padding: 0px; + --icon-width: 94px; + height: var(--icon-width); + margin: 0.4rem 0.0rem; + } + + .button-source { + --icon-width: 50px !important; + margin: 0 !important; + } + + .row { + display: flex; + } + + .thumbnail { + width: var(--icon-width); + height: var(--icon-width); + background-size: contain; + background-repeat: no-repeat; + background-position: left; + border-radius: 0.5rem; + } + + .title { + font-size: 1.1rem; + align-self: center; + flex: 1; + } + + /* *********************************************************** */ + /* the remaining styles are used for the sound animation icon. */ + /* *********************************************************** */ + .bars { + height: 30px; + left: 50%; + margin: -30px 0 0 -20px; + position: relative; + top: 65%; + width: 40px; + } + + .bar { + background: var(--dark-primary-color); + bottom: 1px; + height: 3px; + position: absolute; + width: 3px; + animation: sound 0ms -800ms linear infinite alternate; + display: block; + } + + @keyframes sound { + 0% { + opacity: .35; + height: 3px; + } + 100% { + opacity: 1; + height: 1rem; + } + } + + .bar:nth-child(1) { left: 1px; animation-duration: 474ms; } + .bar:nth-child(2) { left: 5px; animation-duration: 433ms; } + .bar:nth-child(3) { left: 9px; animation-duration: 407ms; } + .bar:nth-child(4) { left: 13px; animation-duration: 458ms; } + /*.bar:nth-child(5) { left: 17px; animation-duration: 400ms; }*/ + /*.bar:nth-child(6) { left: 21px; animation-duration: 427ms; }*/ + /*.bar:nth-child(7) { left: 25px; animation-duration: 441ms; }*/ + /*.bar:nth-child(8) { left: 29px; animation-duration: 419ms; }*/ + /*.bar:nth-child(9) { left: 33px; animation-duration: 487ms; }*/ + /*.bar:nth-child(10) { left: 37px; animation-duration: 442ms; }*/ + + `, + styleMediaBrowserItemTitle, + listStyle, + ]; + } +} + +customElements.define('spc-media-browser-list', MediaBrowserList); diff --git a/src/components/player-body-audiobook.ts b/src/components/player-body-audiobook.ts new file mode 100644 index 0000000..85061a9 --- /dev/null +++ b/src/components/player-body-audiobook.ts @@ -0,0 +1,449 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { state } from 'lit/decorators.js'; +import { + mdiBookOpenVariant, + mdiHeart, + mdiHeartOutline, + mdiMicrophone, +} from '@mdi/js'; + +// our imports. +import { sharedStylesGrid } from '../styles/shared-styles-grid.js'; +import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.js'; +import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; +import { PlayerBodyBase } from './player-body-base'; +import { MediaPlayer } from '../model/media-player'; +import { getIdFromSpotifyUri } from '../services/spotifyplus-service'; +import { IChapter } from '../types/spotifyplus/chapter'; +import { formatDateHHMMSSFromMilliseconds, unescapeHtml } from '../utils/utils.js'; +import { openWindowNewTab } from '../utils/media-browser-utils.js'; +import { GetAudiobookAuthors, GetAudiobookNarrators } from '../types/spotifyplus/audiobook-simplified.js'; + +/** + * Audiobook actions. + */ +enum Actions { + GetPlayingItem = "GetPlayingItem", + AudiobookFavoriteAdd = "AudiobookFavoriteAdd", + AudiobookFavoriteRemove = "AudiobookFavoriteRemove", + AudiobookFavoriteUpdate = "AudiobookFavoriteUpdate", + ChapterFavoriteAdd = "ChapterFavoriteAdd", + ChapterFavoriteRemove = "ChapterFavoriteRemove", + ChapterFavoriteUpdate = "ChapterFavoriteUpdate", +} + + +class PlayerBodyAudiobook extends PlayerBodyBase { + + // private state properties. + @state() private isAudiobookFavorite?: boolean; + @state() private isChapterFavorite?: boolean; + @state() private chapter?: IChapter; + + + /** + * 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(); + + // define actions - audiobook. + const actionAudiobookFavoriteAdd = html` +
+ this.onClickAction(Actions.AudiobookFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionAudiobookFavoriteRemove = html` +
+ this.onClickAction(Actions.AudiobookFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + const actionChapterFavoriteAdd = html` +
+ this.onClickAction(Actions.ChapterFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionChapterFavoriteRemove = html` +
+ this.onClickAction(Actions.ChapterFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + // define supporting icons. + const iconAudiobook = html` +
+ openWindowNewTab(this.chapter?.audiobook.external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + const iconChapter = html` +
+ openWindowNewTab(this.chapter?.external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + const actionEpisodeSummary = html` +
+
+
+ ${iconAudiobook} + ${this.chapter?.audiobook.name} + ${(this.isAudiobookFavorite ? actionAudiobookFavoriteRemove : actionAudiobookFavoriteAdd)} +
+
+ ${iconChapter} + ${this.chapter?.name} + ${(this.isChapterFavorite ? actionChapterFavoriteRemove : actionChapterFavoriteAdd)} +
+
+ +
Released
+
${this.chapter?.release_date || "unknown"}
+
 
+
Duration
+
${formatDateHHMMSSFromMilliseconds(this.chapter?.duration_ms || 0)}
+
 
+
Links
+
+ openWindowNewTab(this.chapter?.audiobook.external_urls.spotify || "")} + slot="media-info-icon-link-s" + > + openWindowNewTab(this.chapter?.external_urls.spotify || "")} + slot="media-info-icon-link-s" + > +
+ +
Edition
+
${this.chapter?.audiobook.edition || "unknown"}
+
 
+
Publisher
+
${this.chapter?.audiobook.publisher || "unknown"}
+ +
Authors
+
${GetAudiobookAuthors(this.chapter?.audiobook, "; ")}
+ +
Narrators
+
${GetAudiobookNarrators(this.chapter?.audiobook, "; ")}
+ +
+ +
+
+
+ +
+
+ `; + + // render html. + return html` +
+
+ ${this.alertError ? html`${this.alertError}` : ""} + ${(() => { + if (this.player.attributes.sp_item_type == 'audiobook') { + return (html` + ${actionEpisodeSummary} + `) + } else { + return (html``) + } + })()} +
+
`; + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return [ + sharedStylesGrid, + sharedStylesMediaInfo, + sharedStylesFavActions, + css` + + .audiobook-info-grid { + grid-template-columns: auto auto 30px auto auto 30px auto auto; + justify-content: left; + } + + .colspan-r2-c5 { + grid-row: 2 / 2; /* grid row 2 */ + grid-column: 5 / 9; /* grid columns 5 thru 8 */ + } + + .colspan-r3-c2 { + grid-row: 3 / 3; /* grid row 3 */ + grid-column: 2 / 9; /* grid columns 2 thru 8 */ + } + + .colspan-r4-c2 { + grid-row: 4 / 4; /* grid row 4 */ + grid-column: 2 / 9; /* grid columns 2 thru 8 */ + } + + ` + ]; + } + + + /** + * Handles the `click` event fired when a control icon is clicked. + * + * @param action Action to execute. + */ + protected override async onClickAction(action: Actions): Promise { + + //// if card is being edited, then don't bother. + //if (this.isCardInEditPreview) { + // return true; + //} + + try { + + // show progress indicator. + this.progressShow(); + + // call service based on requested action, and refresh affected action component. + if (action == Actions.AudiobookFavoriteAdd) { + + await this.spotifyPlusService.SaveAudiobookFavorites(this.player.id, this.chapter?.audiobook.id); + this.updateActions(this.player, [Actions.AudiobookFavoriteUpdate]); + + } else if (action == Actions.AudiobookFavoriteRemove) { + + await this.spotifyPlusService.RemoveAudiobookFavorites(this.player.id, this.chapter?.audiobook.id); + this.updateActions(this.player, [Actions.AudiobookFavoriteUpdate]); + + } else if (action == Actions.ChapterFavoriteAdd) { + + await this.spotifyPlusService.SaveEpisodeFavorites(this.player.id, this.chapter?.id); + this.updateActions(this.player, [Actions.ChapterFavoriteUpdate]); + + } else if (action == Actions.ChapterFavoriteRemove) { + + await this.spotifyPlusService.RemoveEpisodeFavorites(this.player.id, this.chapter?.id); + this.updateActions(this.player, [Actions.ChapterFavoriteUpdate]); + + } 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("Audiobook action failed: \n" + (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.GetPlayingItem) != -1) || (updateActions.length == 0)) { + + // reset favorite indicators. + this.isAudiobookFavorite = undefined; + this.isChapterFavorite = undefined; + + // create promise - update currently playing media item. + const promiseGetPlayingItem = new Promise((resolve, reject) => { + + // get id portion of spotify uri value. + const uriIdMediaItem = getIdFromSpotifyUri(this.player.attributes.media_content_id); + + // call service to retrieve media item that is currently playing. + this.spotifyPlusService.GetChapter(player.id, uriIdMediaItem) + .then(result => { + + // load results, update favorites, and resolve the promise. + this.chapter = result; + + // update favorite settings. + setTimeout(() => { + this.updateActions(player, [Actions.AudiobookFavoriteUpdate, Actions.ChapterFavoriteUpdate]); + }, 50); + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.chapter = undefined; + this.alertErrorSet("Get Episode call failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetPlayingItem); + } + + // was this action chosen to be updated? + if (updateActions.indexOf(Actions.AudiobookFavoriteUpdate) != -1) { + + // create promise - check favorite status. + const promiseCheckAudiobookFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckAudiobookFavorites(player.id, this.chapter?.audiobook.id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isAudiobookFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isAudiobookFavorite = undefined; + this.alertErrorSet("Check Audiobook Favorites failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckAudiobookFavorites); + } + + // was this action chosen to be updated? + if (updateActions.indexOf(Actions.ChapterFavoriteUpdate) != -1) { + + // create promise - check favorite status. + const promiseCheckEpisodeFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckEpisodeFavorites(player.id, this.chapter?.id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isChapterFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isChapterFavorite = undefined; + this.alertErrorSet("Check Episode Favorites failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckEpisodeFavorites); + } + + // 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(); + + // call base class method for post actions update processing. + this.updateActionsComplete(updateActions); + + }); + return true; + + } + catch (error) { + + // clear the progress indicator and set alert error message. + this.progressHide(); + this.alertErrorSet("Audiobook actions refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} + +customElements.define('spc-player-body-audiobook', PlayerBodyAudiobook); diff --git a/src/components/player-body-base.ts b/src/components/player-body-base.ts new file mode 100644 index 0000000..18bbd04 --- /dev/null +++ b/src/components/player-body-base.ts @@ -0,0 +1,363 @@ +// lovelace card imports. +import { css, LitElement, PropertyValues, TemplateResult, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +// our imports. +import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; +import { Store } from '../model/store'; +import { Section } from '../types/section.js'; +import { MediaPlayer } from '../model/media-player'; +import { MediaPlayerState } from '../services/media-control-service'; +import { SpotifyPlusService } from '../services/spotifyplus-service'; +import { isCardInEditPreview } from '../utils/utils'; +import { ProgressEndedEvent } from '../events/progress-ended.js'; +import { ProgressStartedEvent } from '../events/progress-started.js'; + +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from '../constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":player-body-base"); + + +export class PlayerBodyBase extends LitElement { + + // public state properties. + @property({ attribute: false }) protected store!: Store; + + // private state properties. + @state() protected alertError?: string; + @state() protected alertInfo?: string; + + /** MediaPlayer instance created from the configuration entity id. */ + protected player!: MediaPlayer; + + /** SpotifyPlus services instance. */ + protected spotifyPlusService!: SpotifyPlusService; + + /** Indicates if actions are currently being updated. */ + protected isUpdateInProgress!: boolean; + + /** True if the card is in edit preview mode (e.g. being edited); otherwise, false. */ + protected isCardInEditPreview!: boolean; + + /** Indicates if the player is stopped (e.g. not playing anything). */ + protected isPlayerStopped!: boolean | typeof nothing; + + /** Type of media being accessed. */ + protected mediaType!: Section; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(); + + // initialize storage. + this.isUpdateInProgress = false; + this.mediaType = Section.PLAYER; + + } + + + /** + * 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 { + + // set common references from application common storage area. + this.player = this.store.player; + this.spotifyPlusService = this.store.spotifyPlusService; + this.isPlayerStopped = [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED, MediaPlayerState.BUFFERING].includes(this.player.state) && nothing; + + // all html is rendered in the inheriting class. + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return [ + sharedStylesFavActions, + css` + + /* extra styles not defined in shared stylesheets would go here. */ + + .title { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + font-size: 1.15rem; + font-weight: 400; + text-shadow: 0 0 2px var(--spc-player-palette-vibrant); + //color: var(--dark-primary-color); + //color: var(--spc-player-palette-vibrant); + color: var(--spc-player-header-color); + mix-blend-mode: screen; + } + ` + ]; + } + + + /** + * Invoked when the component is added to the document's DOM. + * + * In `connectedCallback()` you should setup tasks that should only occur when + * the element is connected to the document. The most common of these is + * adding event listeners to nodes external to the element, like a keydown + * event handler added to the window. + */ + public connectedCallback() { + + // invoke base class method. + super.connectedCallback(); + + // determine if card configuration is being edited. + this.isCardInEditPreview = isCardInEditPreview(this.store.card); + + } + + + /** + * 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); + + // if we are editing the card configuration, then don't bother updating actions as + // the user cannot display the actions dialog while editing the card configuration. + if (this.isCardInEditPreview) { + return; + } + + // refresh body actions. + this.updateActions(this.store.player, []); + + } + + + /** + * Updates the element. This method reflects property values to attributes. + * It can be overridden to render and keep updated element DOM. + * Setting properties inside this method will *not* trigger + * another update. + * + * @param changedProperties Map of changed properties with old values + * @category updates + */ + protected update(changedProperties: PropertyValues): void { + + // invoke base class method. + super.update(changedProperties); + + // get list of changed property keys. + const changedPropKeys = Array.from(changedProperties.keys()) + + // we only care about "store" property changes at this time, as it contains a + // reference to the "hass" property. we are looking for media_content_id changes. + if (!changedPropKeys.includes('store')) { + return; + } + + // if first render has not happened yet then we will wait for it first. + if (!this.hasUpdated) { + return; + } + + // if card is being edited, then we are done since actions cannot be displayed + // while editing the card configuration. + if (this.isCardInEditPreview) { + return; + } + + let oldMediaContentId: string | undefined = undefined; + let newMediaContentId: string | undefined = undefined; + + // get the old property reference. + const oldStore = changedProperties.get('store') as Store; + if (oldStore) { + + // if a media player was assigned to the store, then get the player media content id. + const oldPlayer = oldStore.player; + if (oldPlayer) { + oldMediaContentId = oldPlayer.attributes.media_content_id; + } + } + + // check if the player reference is set (in case it was set to undefined). + if (this.store.player) { + + // get the current media content id + // if content id not set, then there's nothing left to do. + newMediaContentId = this.store.player.attributes.media_content_id; + if (!newMediaContentId) { + return; + } + } + + // did the content change? if so, then update the actions. + if (oldMediaContentId != newMediaContentId) { + + if (debuglog.enabled) { + debuglog("%c update - player content changed:\n- OLD CONTENT ID = %s\n- NEW CONTENT ID = %s\n- isCardInEditPreview = %s", + "color: gold;", + JSON.stringify(oldMediaContentId), + JSON.stringify(newMediaContentId), + JSON.stringify(isCardInEditPreview(this.store.card)), + ); + } + + // refresh all body actions. + setTimeout(() => { + this.updateActions(this.store.player, []); + }, 200); + + } + + } + + + /** + * Clears the error and informational alert text. + */ + protected alertClear() { + this.alertError = undefined; + this.alertInfo = undefined; + } + + + /** + * Clears the error alert text. + */ + protected alertErrorClear() { + this.alertError = undefined; + } + + + /** + * Sets the alert error message, and clears the informational alert message. + */ + protected alertErrorSet(message: string): void { + this.alertError = message; + this.alertInfo = undefined; + } + + + /** + * Hide visual progress indicator. + */ + protected progressHide(): void { + this.isUpdateInProgress = false; + this.store.card.dispatchEvent(ProgressEndedEvent()); + } + + + /** + * Show visual progress indicator. + */ + protected progressShow(): void { + this.store.card.dispatchEvent(ProgressStartedEvent()); + } + + + /** + * Handles the `click` event fired when a control icon is clicked. + * This method should be overridden by the inheriting class. + * + * @param action Action to execute. + */ + protected async onClickAction(action: any): Promise { + + throw new Error("onClickAction not implemented for action \"" + action + "\"."); + + } + + + /** + * 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 updateActions( + player: MediaPlayer, + updateActions: any[], + ): boolean { + + if (debuglog.enabled) { + debuglog("updateActions - updating actions: %s\n- this.isCardInEditPreview = %s\n- isCardInEditPreview(card) = %s\n- hasCardEditLoadedMediaList:\n%s", + JSON.stringify(Array.from(updateActions.values())), + JSON.stringify(this.isCardInEditPreview), + JSON.stringify(isCardInEditPreview(this.store.card)), + JSON.stringify(Store.hasCardEditLoadedMediaList,null,2), + ); + } + + // check if update is already in progress. + if (!this.isUpdateInProgress) { + this.isUpdateInProgress = true; + } else { + return false; + } + + // if editing the card, then don't bother updating actions as we will not + // display the actions dialog. + if (this.isCardInEditPreview) { + this.isUpdateInProgress = false; + return false; + } + + // if player reference not set then we are done. + if (!player) { + this.isUpdateInProgress = false; + return false; + } + + // if no media content id, then don't bother. + if (!this.player.attributes.media_content_id) { + this.isUpdateInProgress = false; + return false; + } + + // clear alerts. + this.alertClear(); + + // indicate caller can refresh it's actions. + return true; + + } + + + /** + * Should be called when all action updates are complete (e.g. after `Promise.allSettled`). + * + * @param updateActions List of actions that were updated, or an empty list for default actions. + */ + protected updateActionsComplete(updateActions: any[]): void { + + // if editing the card AND the default update actions were requested, then indicate + // the actions have been updated. + // we will only allow the actions to be updated the initial time, as a render will + // occur for every keypress in the editor! + if ((this.isCardInEditPreview) && (updateActions.length == 0)) { + + // update DEFAULT actions complete while in card edit mode; + // update ALL actions will not occur again until track change is detected. + Store.hasCardEditLoadedMediaList[this.mediaType] = true; + + } + + } + +} diff --git a/src/components/player-body-show.ts b/src/components/player-body-show.ts new file mode 100644 index 0000000..9bd59eb --- /dev/null +++ b/src/components/player-body-show.ts @@ -0,0 +1,421 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { state } from 'lit/decorators.js'; +import { + mdiHeart, + mdiHeartOutline, + mdiMicrophone, + mdiPodcast, +} from '@mdi/js'; + +// our imports. +import { sharedStylesGrid } from '../styles/shared-styles-grid.js'; +import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.js'; +import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; +import { PlayerBodyBase } from './player-body-base'; +import { MediaPlayer } from '../model/media-player'; +import { IEpisode } from '../types/spotifyplus/episode'; +import { getIdFromSpotifyUri } from '../services/spotifyplus-service'; +import { formatDateHHMMSSFromMilliseconds, unescapeHtml } from '../utils/utils'; +import { openWindowNewTab } from '../utils/media-browser-utils.js'; + +/** + * Show actions. + */ +enum Actions { + GetPlayingItem = "GetPlayingItem", + EpisodeFavoriteAdd = "EpisodeFavoriteAdd", + EpisodeFavoriteRemove = "EpisodeFavoriteRemove", + EpisodeFavoriteUpdate = "EpisodeFavoriteUpdate", + ShowFavoriteAdd = "ShowFavoriteAdd", + ShowFavoriteRemove = "ShowFavoriteRemove", + ShowFavoriteUpdate = "ShowFavoriteUpdate", +} + + +class PlayerBodyShow extends PlayerBodyBase { + + // private state properties. + @state() private isShowFavorite?: boolean; + @state() private isEpisodeFavorite?: boolean; + @state() private episode?: IEpisode; + + + /** + * 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(); + + // define actions - podcast show. + const actionShowFavoriteAdd = html` +
+ this.onClickAction(Actions.ShowFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionShowFavoriteRemove = html` +
+ this.onClickAction(Actions.ShowFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + const actionEpisodeFavoriteAdd = html` +
+ this.onClickAction(Actions.EpisodeFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionEpisodeFavoriteRemove = html` +
+ this.onClickAction(Actions.EpisodeFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + // define supporting icons. + const iconShow = html` +
+ openWindowNewTab(this.episode?.show.external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + const iconEpisode = html` +
+ openWindowNewTab(this.episode?.external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + const actionEpisodeSummary = html` +
+
+
+ ${iconShow} + ${this.episode?.show.name} + ${(this.isShowFavorite ? actionShowFavoriteRemove : actionShowFavoriteAdd)} +
+
+ ${iconEpisode} + ${this.episode?.name} + ${(this.isEpisodeFavorite ? actionEpisodeFavoriteRemove : actionEpisodeFavoriteAdd)} +
+
+
Released On
+
${this.episode?.release_date || "unknown"}
+
 
+
Duration
+
${formatDateHHMMSSFromMilliseconds(this.episode?.duration_ms || 0)}
+
 
+
Links
+
+ openWindowNewTab(this.episode?.show.external_urls.spotify || "")} + slot="media-info-icon-link-s" + > + openWindowNewTab(this.episode?.external_urls.spotify || "")} + slot="media-info-icon-link-s" + > +
+
+
+
+
+
+
+ `; + + //
+ //
+ //
+ + // render html. + return html` +
+
+ ${this.alertError ? html`${this.alertError}` : ""} + ${(() => { + if (this.player.attributes.sp_item_type == 'podcast') { + return (html` + ${actionEpisodeSummary} + `) + } else { + return (html``) + } + })()} +
+
`; + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return [ + sharedStylesGrid, + sharedStylesMediaInfo, + sharedStylesFavActions, + css` + + .show-info-grid { + grid-template-columns: auto auto 30px auto auto 30px auto auto; + justify-content: left; + } + + ` + ]; + } + + + /** + * 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 { + // show progress indicator. + this.progressShow(); + + // call service based on requested action, and refresh affected action component. + if (action == Actions.ShowFavoriteAdd) { + + await this.spotifyPlusService.SaveShowFavorites(this.player.id, this.episode?.show.id); + this.updateActions(this.player, [Actions.ShowFavoriteUpdate]); + + } else if (action == Actions.ShowFavoriteRemove) { + + await this.spotifyPlusService.RemoveShowFavorites(this.player.id, this.episode?.show.id); + this.updateActions(this.player, [Actions.ShowFavoriteUpdate]); + + } else if (action == Actions.EpisodeFavoriteAdd) { + + await this.spotifyPlusService.SaveEpisodeFavorites(this.player.id, this.episode?.id); + this.updateActions(this.player, [Actions.EpisodeFavoriteUpdate]); + + } else if (action == Actions.EpisodeFavoriteRemove) { + + await this.spotifyPlusService.RemoveEpisodeFavorites(this.player.id, this.episode?.id); + this.updateActions(this.player, [Actions.EpisodeFavoriteUpdate]); + + } 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("Show action failed: \n" + (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.GetPlayingItem) != -1) || (updateActions.length == 0)) { + + // reset favorite indicators. + this.isEpisodeFavorite = undefined; + this.isShowFavorite = undefined; + + // create promise - update currently playing media item. + const promiseGetPlayingItem = new Promise((resolve, reject) => { + + // get id portion of spotify uri value. + const uriIdMediaItem = getIdFromSpotifyUri(this.player.attributes.media_content_id); + + // call service to retrieve media item that is currently playing. + this.spotifyPlusService.GetEpisode(player.id, uriIdMediaItem) + .then(result => { + + // load results, update favorites, and resolve the promise. + this.episode = result; + + // update favorite settings. + setTimeout(() => { + this.updateActions(player, [Actions.ShowFavoriteUpdate, Actions.EpisodeFavoriteUpdate]); + }, 50); + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.episode = undefined; + this.alertErrorSet("Get Episode call failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetPlayingItem); + } + + // was this action chosen to be updated? + if (updateActions.indexOf(Actions.ShowFavoriteUpdate) != -1) { + + // create promise - check favorite status. + const promiseCheckShowFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckShowFavorites(player.id, this.episode?.show.id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isShowFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isShowFavorite = undefined; + this.alertErrorSet("Check Show Favorites failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckShowFavorites); + } + + // was this action chosen to be updated? + if (updateActions.indexOf(Actions.EpisodeFavoriteUpdate) != -1) { + + // create promise - check favorite status. + const promiseCheckEpisodeFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckEpisodeFavorites(player.id, this.episode?.id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isEpisodeFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isEpisodeFavorite = undefined; + this.alertErrorSet("Check Episode Favorites failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckEpisodeFavorites); + } + + // 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(); + + // call base class method for post actions update processing. + this.updateActionsComplete(updateActions); + + }); + return true; + + } + catch (error) { + + // clear the progress indicator and set alert error message. + this.progressHide(); + this.alertErrorSet("Show actions refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} + +customElements.define('spc-player-body-show', PlayerBodyShow); diff --git a/src/components/player-body-track.ts b/src/components/player-body-track.ts new file mode 100644 index 0000000..b45f2c6 --- /dev/null +++ b/src/components/player-body-track.ts @@ -0,0 +1,516 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { state } from 'lit/decorators.js'; +import { + mdiAccountMusic, + mdiAlbum, + mdiHeart, + mdiHeartOutline, + mdiMusic, +} from '@mdi/js'; + +// our imports. +import { sharedStylesGrid } from '../styles/shared-styles-grid.js'; +import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.js'; +import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; +import { PlayerBodyBase } from './player-body-base'; +import { MediaPlayer } from '../model/media-player'; +import { getIdFromSpotifyUri } from '../services/spotifyplus-service'; +import { ITrack } from '../types/spotifyplus/track'; +import { openWindowNewTab } from '../utils/media-browser-utils.js'; +import { formatDateHHMMSSFromMilliseconds } from '../utils/utils.js'; + +/** + * Track actions. + */ +enum Actions { + GetPlayingItem = "GetPlayingItem", + AlbumFavoriteAdd = "AlbumFavoriteAdd", + AlbumFavoriteRemove = "AlbumFavoriteRemove", + AlbumFavoriteUpdate = "AlbumFavoriteUpdate", + ArtistFavoriteAdd = "ArtistFavoriteAdd", + ArtistFavoriteRemove = "ArtistFavoriteRemove", + ArtistFavoriteUpdate = "ArtistFavoriteUpdate", + TrackFavoriteAdd = "TrackFavoriteAdd", + TrackFavoriteRemove = "TrackFavoriteRemove", + TrackFavoriteUpdate = "TrackFavoriteUpdate", +} + + +class PlayerBodyTrack extends PlayerBodyBase { + + // private state properties. + @state() private isAlbumFavorite?: boolean; + @state() private isArtistFavorite?: boolean; + @state() private isTrackFavorite?: boolean; + @state() private track?: ITrack; + + + /** + * 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(); + + // define actions - track. + const actionArtistFavoriteAdd = html` +
+ this.onClickAction(Actions.ArtistFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionArtistFavoriteRemove = html` +
+ this.onClickAction(Actions.ArtistFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + const actionAlbumFavoriteAdd = html` +
+ this.onClickAction(Actions.AlbumFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionAlbumFavoriteRemove = html` +
+ this.onClickAction(Actions.AlbumFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + const actionTrackFavoriteAdd = html` +
+ this.onClickAction(Actions.TrackFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionTrackFavoriteRemove = html` +
+ this.onClickAction(Actions.TrackFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + // define supporting icons. + const iconArtist = html` +
+ openWindowNewTab(this.track?.artists[0].external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + const iconAlbum = html` +
+ openWindowNewTab(this.track?.album.external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + const iconTrack = html` +
+ openWindowNewTab(this.track?.external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + const actionTrackSummary = html` +
+
+
+ ${iconTrack} + ${this.track?.name} + ${(this.isTrackFavorite ? actionTrackFavoriteRemove : actionTrackFavoriteAdd)} +
+
+ ${iconAlbum} + ${this.track?.album.name} + ${(this.isAlbumFavorite ? actionAlbumFavoriteRemove : actionAlbumFavoriteAdd)} +
+
+ ${iconArtist} + ${this.track?.artists[0].name} + ${(this.isArtistFavorite ? actionArtistFavoriteRemove : actionArtistFavoriteAdd)} +
+
+
Track #
+
${this.track?.track_number || "unknown"}
+
 
+
Duration
+
${formatDateHHMMSSFromMilliseconds(this.track?.duration_ms || 0)}
+
 
+
Released
+
${this.track?.album.release_date}
+ +
Disc #
+
${this.track?.disc_number || "unknown"}
+
 
+
Explicit
+
${this.track?.explicit || false}
+
 
+
Links
+
+ openWindowNewTab(this.track?.artists[0].external_urls.spotify || "")} + slot="media-info-icon-link-s" + > + openWindowNewTab(this.track?.album.external_urls.spotify || "")} + slot="media-info-icon-link-s" + > + openWindowNewTab(this.track?.external_urls.spotify || "")} + slot="media-info-icon-link-s" + > +
+ +
+
+
+ `; + + //
+ //
+ //
+ + // render html. + return html` +
+
+ ${this.alertError ? html`${this.alertError}` : ""} + ${(() => { + if (this.player.attributes.sp_item_type == 'track') { + return (html` + ${actionTrackSummary} + `) + } else { + return (html``) + } + })()} +
+
`; + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return [ + sharedStylesGrid, + sharedStylesMediaInfo, + sharedStylesFavActions, + css` + + .track-info-grid { + grid-template-columns: auto auto 30px auto auto 30px auto auto; + justify-content: left; + } + ` + ]; + } + + + /** + * 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 { + // show progress indicator. + this.progressShow(); + + // call service based on requested action, and refresh affected action component. + if (action == Actions.AlbumFavoriteAdd) { + + await this.spotifyPlusService.SaveAlbumFavorites(this.player.id, this.track?.album.id); + this.updateActions(this.player, [Actions.AlbumFavoriteUpdate]); + + } else if (action == Actions.AlbumFavoriteRemove) { + + await this.spotifyPlusService.RemoveAlbumFavorites(this.player.id, this.track?.album.id); + this.updateActions(this.player, [Actions.AlbumFavoriteUpdate]); + + } else if (action == Actions.ArtistFavoriteAdd) { + + await this.spotifyPlusService.FollowArtists(this.player.id, this.track?.artists[0].id); + this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); + + } else if (action == Actions.ArtistFavoriteRemove) { + + await this.spotifyPlusService.UnfollowArtists(this.player.id, this.track?.artists[0].id); + this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); + + } else if (action == Actions.TrackFavoriteAdd) { + + await this.spotifyPlusService.SaveTrackFavorites(this.player.id, this.track?.id); + this.updateActions(this.player, [Actions.TrackFavoriteUpdate]); + + } else if (action == Actions.TrackFavoriteRemove) { + + await this.spotifyPlusService.RemoveTrackFavorites(this.player.id, this.track?.id); + this.updateActions(this.player, [Actions.TrackFavoriteUpdate]); + + } 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("Track action failed: \n" + (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.GetPlayingItem) != -1) || (updateActions.length == 0)) { + + // reset favorite indicators. + this.isAlbumFavorite = undefined; + this.isArtistFavorite = undefined; + this.isTrackFavorite = undefined; + + // create promise - update currently playing media item. + const promiseGetPlayingItem = new Promise((resolve, reject) => { + + // get id portion of spotify uri value. + const uriIdMediaItem = getIdFromSpotifyUri(this.player.attributes.media_content_id); + + // call service to retrieve media item that is currently playing. + this.spotifyPlusService.GetTrack(player.id, uriIdMediaItem) + .then(result => { + + // load results, update favorites, and resolve the promise. + this.track = result; + + // update favorite settings. + setTimeout(() => { + this.updateActions(player, [Actions.TrackFavoriteUpdate, Actions.AlbumFavoriteUpdate, Actions.ArtistFavoriteUpdate]); + }, 50); + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.track = undefined; + this.alertErrorSet("Get Track call failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetPlayingItem); + } + + // was this action chosen to be updated? + if (updateActions.indexOf(Actions.AlbumFavoriteUpdate) != -1) { + + // create promise - check favorite status. + const promiseCheckAlbumFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckAlbumFavorites(player.id, this.track?.album.id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isAlbumFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isAlbumFavorite = undefined; + this.alertErrorSet("Check Album Favorites failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckAlbumFavorites); + } + + // was this action chosen to be updated? + if (updateActions.indexOf(Actions.ArtistFavoriteUpdate) != -1) { + + // create promise - check favorite status. + const promiseCheckArtistFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckArtistsFollowing(player.id, this.track?.artists[0].id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isArtistFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isArtistFavorite = undefined; + this.alertErrorSet("Check Artist Favorites failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckArtistFavorites); + } + + // was this action chosen to be updated? + if (updateActions.indexOf(Actions.TrackFavoriteUpdate) != -1) { + + // create promise - check favorite status. + const promiseCheckTrackFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckTrackFavorites(player.id, this.track?.id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isTrackFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isTrackFavorite = undefined; + this.alertErrorSet("Check Track Favorites failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckTrackFavorites); + } + + // 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(); + + // call base class method for post actions update processing. + this.updateActionsComplete(updateActions); + + }); + return true; + + } + catch (error) { + + // clear the progress indicator and set alert error message. + this.progressHide(); + this.alertErrorSet("Track actions refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} + +customElements.define('spc-player-body-track', PlayerBodyTrack); diff --git a/src/components/player-controls.ts b/src/components/player-controls.ts new file mode 100644 index 0000000..2281c1f --- /dev/null +++ b/src/components/player-controls.ts @@ -0,0 +1,523 @@ +// lovelace card imports. +import { css, html, LitElement, TemplateResult, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { styleMap } from 'lit-html/directives/style-map.js'; +import { + mdiInformationSlabBoxOutline, + mdiPause, + mdiPlay, + mdiPower, + mdiRepeat, + mdiRepeatOff, + mdiRepeatOnce, + mdiShuffle, + mdiShuffleDisabled, + mdiSkipNext, + mdiSkipPrevious, +} from '@mdi/js'; + +// our imports. +import { CardConfig } from '../types/card-config'; +import { Store } from '../model/store'; +import { MediaPlayer } from '../model/media-player'; +import { MediaPlayerEntityFeature, MediaPlayerState, RepeatMode } from '../services/media-control-service'; +import { MediaControlService } from '../services/media-control-service'; +import { ProgressEndedEvent } from '../events/progress-ended'; +import { ProgressStartedEvent } from '../events/progress-started'; +import { closestElement, isCardInEditPreview } from '../utils/utils'; +import { Player } from '../sections/player'; + +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from '../constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":player-controls"); + +const { ACTION_FAVES, NEXT_TRACK, PAUSE, PLAY, PREVIOUS_TRACK, REPEAT_SET, SHUFFLE_SET, TURN_ON } = MediaPlayerEntityFeature; + +class PlayerControls extends LitElement { + + // public state properties. + @property({ attribute: false }) store!: Store; + + // private state properties. + @state() private isActionFavoritesVisible?: boolean; + + /** Card configuration data. */ + private config!: CardConfig; + + /** MediaPlayer instance created from the configuration entity id. */ + private player!: MediaPlayer; + + /** MediaPlayer control service instance. */ + private mediaControlService!: MediaControlService; + + /** True if the card is in edit preview mode (e.g. being edited); otherwise, false. */ + protected isCardInEditPreview!: boolean; + + + /** + * 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 { + + // set common references from application common storage area. + this.config = this.store.config; + this.player = this.store.player; + this.mediaControlService = this.store.mediaControlService; + + const stopped = [MediaPlayerState.ON, MediaPlayerState.PLAYING, MediaPlayerState.PAUSED, MediaPlayerState.BUFFERING].includes(this.player.state) && nothing; + + // set button color based on selected option. + const colorRepeat = [RepeatMode.ONE, RepeatMode.ALL].includes(this.player.attributes.repeat || RepeatMode.OFF); + const colorShuffle = (this.player.attributes.shuffle); + const colorPlay = (this.player.state == MediaPlayerState.PAUSED); + const colorPower = (this.player.state == MediaPlayerState.OFF); + const colorActionFavorites = (this.isActionFavoritesVisible); + + // render html. + // note that the "TURN_ON" feature will only be displayed if the player is off AND if + // the device supports the TURN_ON feature. + return html` +
+
+
+ this.onClickAction(ACTION_FAVES)} hide=${this.hideFeature(ACTION_FAVES)} .path=${mdiInformationSlabBoxOutline} label="More Information" style=${this.styleIcon(colorActionFavorites)} > + this.onClickAction(SHUFFLE_SET)} hide=${this.hideFeature(SHUFFLE_SET)} .path=${this.getShuffleIcon()} label="Shuffle" style=${this.styleIcon(colorShuffle)}> + this.onClickAction(PREVIOUS_TRACK)} hide=${this.hideFeature(PREVIOUS_TRACK)} .path=${mdiSkipPrevious} label="Previous Track"> + this.onClickAction(PLAY)} hide=${this.hideFeature(PLAY)} .path=${mdiPlay} label="Play" style=${this.styleIcon(colorPlay)}> + this.onClickAction(PAUSE)} hide=${this.hideFeature(PAUSE)} .path=${mdiPause} label="Pause"> + this.onClickAction(NEXT_TRACK)} hide=${this.hideFeature(NEXT_TRACK)} .path=${mdiSkipNext} label="Next Track"> + this.onClickAction(REPEAT_SET)} hide=${this.hideFeature(REPEAT_SET)} .path=${this.getRepeatIcon()} label="Repeat" style=${this.styleIcon(colorRepeat)} > +
+ +
+ this.onClickAction(TURN_ON)} hide=${this.hideFeature(TURN_ON)} .path=${mdiPower} label="Turn On" style=${this.styleIcon(colorPower)}> +
+
+ `; + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return css` + .player-controls-container { + margin: 0.75rem 3.25rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + max-width: 40rem; + text-align: center; + overflow: hidden auto; + /*border: 1px solid red; /* FOR TESTING CONTROL LAYOUT CHANGES */ + } + + .player-volume-container { + display: block; + height: 2.5rem; + } + + .icons { + justify-content: center; + display: inline-flex; + align-items: center; + --mdc-icon-button-size: 2.5rem !important; + --mdc-icon-size: 1.75rem !important; + mix-blend-mode: screen; + overflow: hidden; + text-shadow: 0 0 2px var(--spc-player-palette-vibrant); + color: white; + } + + .iconsPower { + justify-content: center; + display: block; + align-items: center; + --mdc-icon-button-size: 2.5rem !important; + --mdc-icon-size: 2.5rem !important; + overflow: hidden; + color: white; + /* mix-blend-mode: screen; */ + /* text-shadow: 0 0 2px var(--spc-player-palette-vibrant); */ + } + + *[hide] { + display: none; + } + + .flex-1 { + flex: 1; + } + `; + } + + // bound event listeners for event handlers that need access to "this" object. + private OnKeyDown_EventListenerBound; + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(); + + // create bound event listeners for event handlers that need access to "this" object. + this.OnKeyDown_EventListenerBound = this.OnKeyDown.bind(this); + } + + + /** + * Invoked when the component is added to the document's DOM. + * + * In `connectedCallback()` you should setup tasks that should only occur when + * the element is connected to the document. The most common of these is + * adding event listeners to nodes external to the element, like a keydown + * event handler added to the window. + */ + public connectedCallback() { + + // invoke base class method. + super.connectedCallback(); + + // add document level event listeners. + document.addEventListener("keydown", this.OnKeyDown_EventListenerBound); + + // determine if card configuration is being edited. + this.isCardInEditPreview = isCardInEditPreview(this.store.card); + + } + + + /** + * Invoked when the component is removed from the document's DOM. + * + * This callback is the main signal to the element that it may no longer be + * used. `disconnectedCallback()` should ensure that nothing is holding a + * reference to the element (such as event listeners added to nodes external + * to the element), so that it is free to be garbage collected. + * + * An element may be re-connected after being disconnected. + */ + public disconnectedCallback() { + + // remove document level event listeners. + document.removeEventListener("keydown", this.OnKeyDown_EventListenerBound); + + // invoke base class method. + super.disconnectedCallback(); + } + + + /** + * KeyDown event handler. + * + * @ev Event arguments. + */ + private OnKeyDown(ev: KeyboardEvent) { + + // if ESC pressed, then hide the actions section if it's visible. + if (ev.key === "Escape") { + if (this.isActionFavoritesVisible) + this.onClickAction(ACTION_FAVES); + } + + } + + + /** + * Handles the `click` event fired when a control icon is clicked. + * + * @param action Action to execute. + */ + private async onClickAction(action: MediaPlayerEntityFeature): Promise { + + try { + + // handle action(s) that don't require a progress indicator. + if (action == ACTION_FAVES) { + + if (debuglog.enabled) { + debuglog("onClickAction - event handler:\n- action = %s", + JSON.stringify(action), + ); + } + + // if we are editing the card configuration, then we don't want to allow this. + // this is because the `firstUpdated` method will fire every time the configuration + // changes (e.g. every keypress) and action status is lost. + if (this.isCardInEditPreview) { + this.alertInfoSet("Action info cannot be displayed while editing card configuration"); + return true; + } + + // toggle action visibility. + const elmBody = this.parentElement?.querySelector(".player-section-body-content") as HTMLElement; + this.isActionFavoritesVisible = !this.isActionFavoritesVisible; + if (elmBody) { + elmBody.style.display = (this.isActionFavoritesVisible) ? "block" : "none"; + elmBody.style.opacity = (this.isActionFavoritesVisible) ? "1" : "0"; + } + + return true; + } + + // show progress indicator. + this.progressShow(); + + // call async service based on requested action. + if (action == PAUSE) { + + await this.mediaControlService.media_pause(this.player); + + } else if (action == PLAY) { + + await this.mediaControlService.media_play(this.player); + + } else if (action == NEXT_TRACK) { + + await this.mediaControlService.media_next_track(this.player); + + } else if (action == PREVIOUS_TRACK) { + + await this.mediaControlService.media_previous_track(this.player); + + } else if (action == REPEAT_SET) { + + let repeat_mode = RepeatMode.OFF; + if (this.player.attributes.repeat == RepeatMode.OFF) { + repeat_mode = RepeatMode.ONE; + } else if (this.player.attributes.repeat == RepeatMode.ONE) { + repeat_mode = RepeatMode.ALL; + } else if (this.player.attributes.repeat == RepeatMode.ALL) { + repeat_mode = RepeatMode.OFF; + } + await this.mediaControlService.repeat_set(this.player, repeat_mode); + + } else if (action == SHUFFLE_SET) { + + await this.mediaControlService.shuffle_set(this.player, !this.player.attributes.shuffle); + + //} else if (action == TURN_OFF) { + + // this.mediaControlService.turn_off(this.player); + + } else if (action == TURN_ON) { + + await this.mediaControlService.turn_on(this.player); + + } else { + + // no action selected - hide progress indicator. + this.progressHide(); + + } + + // hide progress indicator. + this.progressHide(); + return true; + + } + catch (error) { + + // clear the progress indicator and set alert error message. + this.progressHide(); + this.alertErrorSet("Control action failed: \n" + (error as Error).message); + return true; + + } + finally { + } + + } + + + /** + * Returns `nothing` if the specified feature is to be SHOWN; otherwise, any + * other value will cause the feature icon to be hidden (weird logic, I know). + * + * The feature will be hidden from view if the media player does not support it, + * or if the configuration settings "playerControlsHideX" is true. + * + * @param feature Feature identifier to check. + */ + private hideFeature(feature: MediaPlayerEntityFeature) { + + if (feature == PAUSE) { + + if (this.player.supportsFeature(PAUSE)) { + // if already paused, then hide it! + if (this.player.state == MediaPlayerState.PAUSED) { + return; + } + return this.config.playerControlsHidePlayPause || nothing; + } + + } else if (feature == PLAY) { + + if (this.player.supportsFeature(PLAY)) { + // if already playing, then hide it! + if (this.player.state == MediaPlayerState.PLAYING) { + return; + } + return this.config.playerControlsHidePlayPause || nothing; + } + + } else if (feature == NEXT_TRACK) { + + if (this.player.supportsFeature(NEXT_TRACK)) + return this.config.playerControlsHideTrackNext || nothing; + + } else if (feature == PREVIOUS_TRACK) { + + if (this.player.supportsFeature(PREVIOUS_TRACK)) + return this.config.playerControlsHideTrackPrev || nothing; + + } else if (feature == REPEAT_SET) { + + if (this.player.supportsFeature(REPEAT_SET)) + return this.config.playerControlsHideRepeat || nothing; + + } else if (feature == SHUFFLE_SET) { + + if (this.player.supportsFeature(SHUFFLE_SET)) + return this.config.playerControlsHideShuffle || nothing; + + } else if (feature == ACTION_FAVES) { + + return this.config.playerControlsHideFavorites || nothing; + + } 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 true; // hide icon + } + + //} 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 + // } + + } + + // default is to hide the feature. + return true; + + } + + + /** + * Hide visual progress indicator. + */ + private progressHide(): void { + this.store.card.dispatchEvent(ProgressEndedEvent()); + } + + + /** + * Show visual progress indicator. + */ + private progressShow(): void { + this.store.card.dispatchEvent(ProgressStartedEvent()); + } + + + /** + * Sets the alert info message in the parent player. + */ + private alertInfoSet(message: string): void { + + // find the parent player reference, and update the message. + // we have to do it this way due to the shadowDOM between this + // element and the player element. + const spcPlayer = closestElement('#spcPlayer', this) as Player; + if (spcPlayer) { + spcPlayer.alertInfoSet(message); + } + + } + + + /** + * Sets the alert error message in the parent player. + */ + private alertErrorSet(message: string): void { + + // find the parent player reference, and update the message. + // we have to do it this way due to the shadowDOM between this + // element and the player element. + const spcPlayer = closestElement('#spcPlayer', this) as Player; + if (spcPlayer) { + spcPlayer.alertErrorSet(message); + } + + } + + + /** + * Returns the icon to display for the repeat button. + */ + private getRepeatIcon() { + + if (this.player.attributes.repeat == RepeatMode.ALL) { + return mdiRepeat; + } else if (this.player.attributes.repeat == RepeatMode.ONE) { + return mdiRepeatOnce; + } else { + return mdiRepeatOff; + } + + } + + + /** + * Returns the icon to display for the shuffle button. + */ + private getShuffleIcon() { + + if (this.player.attributes.shuffle) { + return mdiShuffle; + } else { + return mdiShuffleDisabled; + } + + } + + + /** + * Returns an element style for the progress bar portion of the control. + */ + private styleContainer() { + return styleMap({ + 'margin-bottom': '0px;', // cannot place this in class (player-controls-container), must be placed here! + }); + } + + + /** + * Returns an element style for control icon coloring. + */ + private styleIcon(isColored: boolean | undefined): string | undefined { + + // color the button if desired. + if (isColored) { + return `color: var(--dark-primary-color);`; + } + + return undefined; + } + +} + +customElements.define('spc-player-controls', PlayerControls); diff --git a/src/components/player-header.ts b/src/components/player-header.ts new file mode 100644 index 0000000..c8dc8db --- /dev/null +++ b/src/components/player-header.ts @@ -0,0 +1,134 @@ +// lovelace card imports. +import { css, html, LitElement, TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit-html/directives/style-map.js'; + +// our imports. +import '../components/player-progress'; +import { CardConfig } from '../types/card-config'; +import { Store } from '../model/store'; +import { MediaPlayer } from '../model/media-player'; +import { formatTitleInfo } from '../utils/media-browser-utils'; + +class PlayerHeader extends LitElement { + + /** Application common storage area. */ + @property({ attribute: false }) store!: Store; + + /** Card configuration data. */ + private config!: CardConfig; + + /** MediaPlayer instance created from the configuration entity id. */ + private player!: MediaPlayer; + + + /** + * 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 { + + // set common references from application common storage area. + this.config = this.store.config; + this.player = this.store.player; + + // get hide progress bar configuration setting. + const hideProgress = this.config.playerHeaderHideProgressBar || false; + + // format title and sub-title details. + const title = formatTitleInfo(this.config.playerHeaderTitle, this.config, this.player); + let artistTrack = formatTitleInfo(this.config.playerHeaderArtistTrack, this.config, this.player); + let album = formatTitleInfo(this.config.playerHeaderAlbum, this.config, this.player); + + // just in case nothing is playing, get rid of any ' - ' sequences. + if (artistTrack) { + artistTrack = artistTrack.replace(/^ - | - $/g, ''); + } + + // 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'; + album = undefined; + } + + // render html. + return html` +
+ ${!hideProgress ? html`` : html``} +
${title}
+ ${artistTrack ? html`
${artistTrack}
` : html``} + ${album ? html`
${album}
` : html``} +
`; + } + + /** + * Returns an element style for the progress bar portion of the control. + */ + private styleContainer() { + return styleMap({ + }); + } + + + /** + * style definitions used by this component. + * */ +static get styles() { + return css` + + .player-header-container { + margin: 0.75rem 3.25rem; + margin-top: 0rem; + padding: 0.5rem; + padding-top: 0rem; + max-width: 40rem; + text-align: center; + background-position: center; + background-repeat: no-repeat; + background-size: contain; + /*border: 1px solid red; /* FOR TESTING CONTROL LAYOUT CHANGES */ + } + + .header-title { + overflow: hidden; + text-overflow: ellipsis; + font-size: 1rem; + font-weight: 500; + text-shadow: 0 0 2px var(--spc-player-palette-vibrant); + //color: var(--secondary-text-color); + //color: var(--spc-player-palette-vibrant); + color: var(--spc-player-header-color); + white-space: nowrap; + mix-blend-mode: screen; + min-height: 0.5rem; + } + + .header-artist-track { + overflow: hidden; + text-overflow: ellipsis; + font-size: 1.15rem; + font-weight: 400; + text-shadow: 0 0 2px var(--spc-player-palette-vibrant); + //color: var(--dark-primary-color); + //color: var(--spc-player-palette-vibrant); + color: var(--spc-player-header-color); + mix-blend-mode: screen; + } + + .header-artist-album { + overflow: hidden; + text-overflow: ellipsis; + font-size: 1rem; + font-weight: 300; + text-shadow: 0 0 2px var(--spc-player-palette-vibrant); + //color: var(--secondary-text-color); + //color: var(--spc-player-palette-vibrant); + color: var(--spc-player-header-color); + mix-blend-mode: screen; + } + `; + } +} + +customElements.define('spc-player-header', PlayerHeader); diff --git a/src/components/player-progress.ts b/src/components/player-progress.ts new file mode 100644 index 0000000..585f5c5 --- /dev/null +++ b/src/components/player-progress.ts @@ -0,0 +1,200 @@ +// lovelace card imports. +import { css, html, LitElement, TemplateResult } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { styleMap } from 'lit-html/directives/style-map.js'; + +// our imports. +import { Store } from '../model/store'; +import { MediaPlayer } from '../model/media-player'; + +class Progress extends LitElement { + + /** Application common storage area. */ + @property({ attribute: false }) store!: Store; + + /** MediaPlayer instance created from the configuration entity id. */ + private player!: MediaPlayer; + + /** Current position (in seconds) of the currently playing media. */ + @state() private playingProgress!: number; + + /** Callback function that calculates the current progress (executed every 1 second). */ + private tracker?: NodeJS.Timeout; + + /** Progress bar HTMLElement control. */ + @query('.bar') private progressBar?: HTMLElement; + + /** Current media duration value. */ + private mediaDuration = 0; + + + /** + * 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 { + + // set common references from application common storage area. + this.player = this.store.player; + + // get media duration, and check if it progressing. + this.mediaDuration = this.player?.attributes.media_duration || 0; + const hasProgress = this.mediaDuration > 0; + + // show / hide the progress bar. + if (hasProgress) { + + // start tracking progress. + this.trackProgress(); + + // render control. + return html` +
+ ${convertProgress(this.playingProgress)} +
+
+
+ -${convertProgress(this.mediaDuration - this.playingProgress)} +
+ `; + } else { + return html``; + } + } + + + /** + * Invoked when the component is removed from the document's DOM. + * + * This callback is the main signal to the element that it may no longer be + * used. `disconnectedCallback()` should ensure that nothing is holding a + * reference to the element (such as event listeners added to nodes external + * to the element), so that it is free to be garbage collected. + * + * An element may be re-connected after being disconnected. + */ + disconnectedCallback() { + + // are we currently tracking progress? if so, then stop tracking and + // indicate we are no longer tracking. + if (this.tracker) { + clearInterval(this.tracker); + this.tracker = undefined; + } + + // invoke base class method. + super.disconnectedCallback(); + } + + + /** + * Handles the `click` event fired when the seek bar is clicked. + * + * This will seek to the desired position of the playing track. + * + * @param args Event arguments that contain the mouse pointer position. + */ + private async onSeekBarClick(args: MouseEvent) { + + // calculate the desired position based on the mouse pointer position. + const progressWidth = this.progressBar!.offsetWidth; + const percent = args.offsetX / progressWidth; + const position = this.mediaDuration * percent; + + // call service to seek to track position. + await this.store.mediaControlService.media_seek(this.player, position); + } + + + /** + * Returns an element style for the progress bar portion of the control. + */ + private styleProgressBar(mediaDuration: number) { + return styleMap({ width: `${(this.playingProgress / mediaDuration) * 100}%` }); + } + + + /** + * Starts progress tracking; this method is repeatedly called at 1 second intervals + * to update the position of the track. + * + * It is stopped when the control instance is unloaded from the DOM (via disconnectedCallback). + */ + private trackProgress() { + + // 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; + + // calculate progress. + if (playing) { + this.playingProgress = position + (Date.now() - new Date(updatedAt).getTime()) / 1000.0; + } else { + this.playingProgress = position; + } + + // start tracking progress at 1 second intervals. + if (!this.tracker) { + this.tracker = setInterval(() => this.trackProgress(), 1000); + } + + // if we are not playing, then clear the interval. + if (!playing) { + clearInterval(this.tracker); + this.tracker = undefined; + } + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return css` + .progress { + width: 100%; + font-size: x-small; + display: flex; + color: var(--spc-player-controls-color); + } + + .bar { + display: flex; + flex-grow: 1; + align-items: center; + padding: 5px; + cursor: pointer; + } + + .progress-bar { + background-color: var(--dark-primary-color); + height: 50%; + transition: width 0.1s linear; + } + + .progress-time { + mix-blend-mode: screen; + } + `; + } +} + + +/** + * Converts a duration (in seconds) value to a current time value. + * + * @param duration Duration to convert, specified in seconds. + */ +const convertProgress = (duration: number) => { + + // create a timestamp from the current duration value. + const date = new Date(duration * 1000).toISOString().substring(11, 19); + + // return the minutes and seconds portion of the timestamp. + return date.startsWith('00:') ? date.substring(3) : date; +}; + + +customElements.define('spc-player-progress', Progress); diff --git a/src/components/player-volume.ts b/src/components/player-volume.ts new file mode 100644 index 0000000..59b76d7 --- /dev/null +++ b/src/components/player-volume.ts @@ -0,0 +1,326 @@ +// lovelace card imports. +import { css, html, LitElement, TemplateResult, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { + mdiPower, + mdiVolumeHigh, + mdiVolumeMute +} from '@mdi/js'; + +// our imports. +import { CardConfig } from '../types/card-config'; +import { Store } from '../model/store'; +import { MediaPlayer } from '../model/media-player'; +import { MediaPlayerEntityFeature, MediaPlayerState } from '../services/media-control-service'; +import { MediaControlService } from '../services/media-control-service'; +import { ProgressEndedEvent } from '../events/progress-ended'; +import { ProgressStartedEvent } from '../events/progress-started'; +import { closestElement } from '../utils/utils'; +import { Player } from '../sections/player'; + +const { TURN_OFF, TURN_ON } = MediaPlayerEntityFeature; + + +class Volume extends LitElement { + + // public state properties. + @property({ attribute: false }) store!: Store; + @property({ attribute: false }) player!: MediaPlayer; + @property() slim: boolean = false; + + /** Card configuration data. */ + private config!: CardConfig; + + /** MediaControlService services helper instance. */ + private mediaControlService!: MediaControlService; + + + /** + * 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 { + + // set common references from application common storage area. + this.config = this.store.config; + this.mediaControlService = this.store.mediaControlService; + + // get volume hide configuration setting. + const hideMute = this.config.playerVolumeControlsHideMute || false; + const muteIcon = this.player.isMuted() ? mdiVolumeMute : mdiVolumeHigh; + + // set button color based on selected option. + const colorPower = (this.player.state == MediaPlayerState.OFF); + const colorMute = (this.player.attributes.is_volume_muted); + + // get current and max volume levels. + const volume = this.player.getVolume(); + const maxVolume = 100; // this.getMaxVolume(volume); + + // render control. + return html` +
+ ${!hideMute ? html`` : html``} +
+ +
+
0%
+
${Math.round(volume)}%
+
${maxVolume}%
+
+
+ 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"> +
+ `; + } + + + /** + * Handles the `value-changed` event fired when the volume slider is changed. + * + * @param args Event arguments. + */ + private async OnVolumeValueChanged(args: Event) { + const newVolume = Number.parseInt((args?.target as HTMLInputElement)?.value); + return await this.mediaControlService.volume_set(this.player, newVolume); + } + + + /** + * Handles the `click` event fired when the mute button is clicked. + */ + private async OnMuteClick() { + return await this.mediaControlService.volume_mute_toggle(this.player); + } + + + /** + * Returns an element style for the volume slider portion of the control. + */ + private styleVolumeSlider(): string | undefined { + + // show / hide the header. + const hideSlider = this.config.playerVolumeControlsHideSlider || false; + if (hideSlider) + return `display: none`; + + return + } + + + /** + * Handles the `click` event fired when a control icon is clicked. + * + * @param action Action to execute. + */ + private async onClickAction(action: MediaPlayerEntityFeature): Promise { + + try { + + // show progress indicator. + this.progressShow(); + + // call async service based on requested action. + if (action == TURN_OFF) { + + this.mediaControlService.turn_off(this.player); + + } else if (action == TURN_ON) { + + this.mediaControlService.turn_on(this.player); + + } else { + + // no action selected - hide progress indicator. + this.progressHide(); + + } + + // hide progress indicator. + this.progressHide(); + return true; + + } + catch (error) { + + // clear the progress indicator and set alert error message. + this.progressHide(); + this.alertErrorSet("Volume action failed: \n" + (error as Error).message); + return true; + + } + finally { + } + + } + + + /** + * Returns `nothing` if the specified feature is to be hidden from view. + * The feature will be hidden from view if the media player does not support it, + * or if the configuration settings "playerControlsHideX" is true. + * + * @param feature Feature identifier to check. + */ + private hideFeature(feature: MediaPlayerEntityFeature) { + + if (feature == TURN_ON) { + + 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 true; // hide icon + } + + } else if (feature == TURN_OFF) { + + 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 true; // hide icon + } + } + + // default is to hide the feature. + return true; + + } + + + /** + * Hide visual progress indicator. + */ + protected progressHide(): void { + this.store.card.dispatchEvent(ProgressEndedEvent()); + } + + + /** + * Show visual progress indicator. + */ + protected progressShow(): void { + this.store.card.dispatchEvent(ProgressStartedEvent()); + } + + + /** + * Sets the alert error message in the parent player. + * + * @param message alert message text. + */ + private alertErrorSet(message: string): void { + + // find the parent player reference, and update the message. + // we have to do it this way due to the shadowDOM between this element and the player element. + const spcPlayer = closestElement('#spcPlayer', this) as Player; + if (spcPlayer) { + spcPlayer.alertErrorSet(message); + } + + } + + + /** + * Returns an element style for control icon coloring. + */ + private styleIcon(isColored: boolean | undefined): string | undefined { + + // color the button if desired. + if (isColored) { + return `color: var(--dark-primary-color);`; + } + + return undefined; + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return css` + ha-control-slider { + --control-slider-color: var(--dark-primary-color); + --control-slider-thickness: 1rem; + } + + ha-control-slider[disabled] { + --control-slider-color: var(--disabled-text-color); + --control-slider-thickness: 1rem; + } + + .volume-container { + display: flex; + flex: 1; + justify-content: space-between; + mix-blend-mode: screen; + color: var(--spc-player-controls-color); + /*border: 1px solid blue; /* FOR TESTING CONTROL LAYOUT CHANGES */ + } + + .volume-slider { + flex: 1; + padding-right: 0.0rem; + align-content: flex-end; + } + + .volume-level { + font-size: x-small; + display: flex; + } + + .volume-percentage { + flex: 2; + padding-left: 2px; + padding-right: 2px; + font-weight: normal; + font-size: 10px; + /*text-shadow: 0 0 2px var(--dark-primary-color); */ + color: var(--dark-primary-color); + } + + *[slim] * { + --control-slider-thickness: 10px; + --mdc-icon-button-size: 30px; + --mdc-icon-size: 20px; + } + + *[slim] .volume-level { + display: none; + } + + *[slim] .volume-slider { + display: flex; + align-items: center; + } + + .icons { + justify-content: center; + display: inline-flex; + align-items: center; + --mdc-icon-button-size: 2.5rem !important; + --mdc-icon-size: 1.75rem !important; + mix-blend-mode: screen; + overflow: hidden; + text-shadow: 0 0 2px var(--spc-player-palette-vibrant); + color: white; + width: 100%; + } + + *[hide] { + display: none; + } + `; + } +} + + +customElements.define('spc-player-volume', Volume); diff --git a/src/components/playlist-actions.ts b/src/components/playlist-actions.ts new file mode 100644 index 0000000..c769a15 --- /dev/null +++ b/src/components/playlist-actions.ts @@ -0,0 +1,380 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { + mdiHeart, + mdiHeartOutline, + mdiPlay, + mdiPlaylistPlay, +} from '@mdi/js'; + +// our imports. +import { sharedStylesGrid } from '../styles/shared-styles-grid.js'; +import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.js'; +import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; +import { FavActionsBase } from './fav-actions-base'; +import { Section } from '../types/section'; +import { MediaPlayer } from '../model/media-player'; +import { formatDateHHMMSSFromMilliseconds, unescapeHtml } from '../utils/utils'; +import { GetPlaylistPagePlaylistTracks } from '../types/spotifyplus/playlist-page.js'; +import { IPlaylistSimplified } from '../types/spotifyplus/playlist-simplified.js'; +import { IPlaylistTrack } from '../types/spotifyplus/playlist-track.js'; +import { openWindowNewTab } from '../utils/media-browser-utils.js'; + +/** + * Playlist actions. + */ +enum Actions { + PlaylistFavoriteAdd = "PlaylistFavoriteAdd", + PlaylistFavoriteRemove = "PlaylistFavoriteRemove", + PlaylistFavoriteUpdate = "PlaylistFavoriteUpdate", + PlaylistItemsUpdate = "PlaylistItemsUpdate", +} + + +class PlaylistActions extends FavActionsBase { + + // public state properties. + @property({ attribute: false }) mediaItem!: IPlaylistSimplified; + + // private state properties. + @state() private playlistTracks?: Array; + @state() private isPlaylistFavorite?: boolean; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.PLAYLIST_FAVORITES); + + } + + + /** + * 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(); + + // define actions. + const actionPlaylistFavoriteAdd = html` +
+ this.onClickAction(Actions.PlaylistFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionPlaylistFavoriteRemove = html` +
+ this.onClickAction(Actions.PlaylistFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + // define supporting icons. + const iconPlaylist = html` +
+ openWindowNewTab(this.mediaItem.external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + // render html. + return html` +
+ ${this.alertError ? html`${this.alertError}` : ""} +
+
+
+ +
+ ${iconPlaylist} + ${this.mediaItem.name} + ${(this.isPlaylistFavorite ? actionPlaylistFavoriteRemove : actionPlaylistFavoriteAdd)} +
+
+ +
# Tracks
+
${this.mediaItem.tracks.total}
+
 
+
Collaborative?
+
${String(this.mediaItem.collaborative || false)}
+ +
# Followers
+
${this.mediaItem.owner.followers.total || 0}
+
 
+
Public?
+
${String(this.mediaItem.public || false)}
+ +
Snapshot ID
+
${this.mediaItem.snapshotId}
+ +
Owned By
+ ${this.mediaItem.owner ? html` + + ` : html`
unknown
`} + +
+
+
+
+
+
+
 
+
#
+
Title
+
Artist
+
Album
+
Duration
+ ${this.playlistTracks?.map((item, index) => html` + this.onClickMediaItem(item.track)} + slot="icon-button" + >  +
${index + 1}
+
${item.track.name || ""}
+
${item.track?.artists[0].name || ""}
+
${item.track?.album.name || ""}
+
${formatDateHHMMSSFromMilliseconds(item.track.duration_ms || 0)}
+ `)} +
+
+
`; + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return [ + sharedStylesGrid, + sharedStylesMediaInfo, + sharedStylesFavActions, + css` + + .playlist-info-grid { + grid-template-columns: 80px auto 10px auto auto; + justify-content: left; + margin: 0.5rem; + max-width: 21rem; + } + + .playlist-actions-container { + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + } + + .colspan-r3-c2 { + grid-row: 3 / 3; /* grid row 3 */ + grid-column: 2 / 6; /* grid columns 2 thru 5 */ + } + + .colspan-r4-c2 { + grid-row: 4 / 4; /* grid row 4 */ + grid-column: 2 / 6; /* grid columns 2 thru 5 */ + } + + /* style tracks container and grid */ + .tracks-grid { + grid-template-columns: 30px 45px auto auto auto 60px; + margin-top: 1.0rem; + } + + /* style ha-icon-button controls in tracks grid: icon size, title text */ + .tracks-grid > ha-icon-button[slot="icon-button"] { + --mdc-icon-button-size: 24px; + --mdc-icon-size: 20px; + vertical-align: top; + padding: 0px; + } + + ` + ]; + } + + + /** + * 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; + } + + // show progress indicator. + this.progressShow(); + + // call service based on requested action, and refresh affected action component. + if (action == Actions.PlaylistFavoriteAdd) { + + await this.spotifyPlusService.FollowPlaylist(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.PlaylistFavoriteUpdate]); + + } else if (action == Actions.PlaylistFavoriteRemove) { + + await this.spotifyPlusService.UnfollowPlaylist(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.PlaylistFavoriteUpdate]); + + } else { + + // no action selected - hide progress indicator. + this.progressHide(); + + } + + return true; + } + + + /** + * 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.PlaylistItemsUpdate) != -1) || (updateActions.length == 0)) { + + // create promise - get action list data. + const promiseGetPlaylistItems = new Promise((resolve, reject) => { + + const limit_total = this.mediaItem.tracks.total; + const market = null; + const fields = null; + const additional_types = null; + + // I would like to only return the necessary fields to populate the playlist items + // grid here, but cannot due to an issue with the Spotify web api. it has been found that the + // spotify web api will only return a value of 50 (maximum) or less in the page `total` value if + // the`fields` argument is supplied. the API will return the total number of playlist items in + // the page `total` value if the`fields` argument is NOT supplied. A good playlist id to test this + // on is`1XhVM7jWPrGLTiNiAy97Za`, which is the largest playlist on spotify (4700+ items). + + //const fields = null; // "items(track(name,id,uri,type,duration_ms,album(name,uri),artists(name,uri)))"; + + // call service to retrieve playlist items. + this.spotifyPlusService.GetPlaylistItems(player.id, this.mediaItem.id, 0, 0, market, fields, additional_types, limit_total) + .then(page => { + + // stash the result into state, and resolve the promise. + this.playlistTracks = GetPlaylistPagePlaylistTracks(page); + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.playlistTracks = undefined; + this.alertErrorSet("Get Playlist Items failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetPlaylistItems); + } + + // was this action chosen to be updated? + if ((updateActions.indexOf(Actions.PlaylistFavoriteUpdate) != -1) || (updateActions.length == 0)) { + + // create promise - check favorite status. + const promiseCheckPlaylistFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckPlaylistFollowers(player.id, this.mediaItem.id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isPlaylistFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isPlaylistFavorite = undefined; + this.alertErrorSet("Check Playlist Followers failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckPlaylistFavorites); + } + + // 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("Playlist actions refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} + +customElements.define('spc-playlist-actions', PlaylistActions); diff --git a/src/components/show-actions.ts b/src/components/show-actions.ts new file mode 100644 index 0000000..69bf628 --- /dev/null +++ b/src/components/show-actions.ts @@ -0,0 +1,349 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { + mdiHeart, + mdiHeartOutline, + mdiPlay, + mdiPodcast, +} from '@mdi/js'; + +// our imports. +import { sharedStylesGrid } from '../styles/shared-styles-grid.js'; +import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.js'; +import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; +import { FavActionsBase } from './fav-actions-base'; +import { Section } from '../types/section'; +import { MediaPlayer } from '../model/media-player'; +import { GetResumeInfo } from '../types/spotifyplus/resume-point'; +import { GetCopyrights } from '../types/spotifyplus/copyright'; +import { IShowSimplified } from '../types/spotifyplus/show-simplified'; +import { formatDateHHMMSSFromMilliseconds, unescapeHtml } from '../utils/utils'; +import { IEpisodePageSimplified } from '../types/spotifyplus/episode-page-simplified'; +import { openWindowNewTab } from '../utils/media-browser-utils'; + +/** + * Show actions. + */ +enum Actions { + ShowFavoriteAdd = "ShowFavoriteAdd", + ShowFavoriteRemove = "ShowFavoriteRemove", + ShowFavoriteUpdate = "ShowFavoriteUpdate", + ShowEpisodesUpdate = "ShowEpisodesUpdate", +} + + +class ShowActions extends FavActionsBase { + + // public state properties. + @property({ attribute: false }) mediaItem!: IShowSimplified; + + // private state properties. + @state() private showEpisodes?: IEpisodePageSimplified; + @state() private isShowFavorite?: boolean; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.SHOW_FAVORITES); + + } + + + /** + * 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(); + + // define actions. + const actionShowFavoriteAdd = html` +
+ this.onClickAction(Actions.ShowFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionShowFavoriteRemove = html` +
+ this.onClickAction(Actions.ShowFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + // define supporting icons. + const iconShow = html` +
+ openWindowNewTab(this.mediaItem.external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + // render html. + // mediaItem will be an IShow object when displaying favorites. + // mediaItem will be an IShowSimplified object when displaying search results, + // and the copyright attribute will not exist. + return html` +
+ ${this.alertError ? html`${this.alertError}` : ""} +
+
+
+
+ ${iconShow} + ${this.mediaItem.name} + ${(this.isShowFavorite ? actionShowFavoriteRemove : actionShowFavoriteAdd)} +
+
+
# Episodes
+
${this.mediaItem.total_episodes || "unknown"}
+ +
Explicit?
+
${this.mediaItem.explicit || false}
+ +
Publisher
+
${this.mediaItem.publisher || "unknown"}
+ + ${("copyrights" in this.mediaItem) ? html` +
Copyright
+
${GetCopyrights(this.mediaItem, "; ")}
+ ` : ""} + +
+
+
+
+
+
+
 
+
Title
+
Status
+
Duration
+ ${this.showEpisodes?.items.map((item) => html` + this.onClickMediaItem(item)} + slot="icon-button" + >  +
${item.name}
+
${GetResumeInfo(item.resume_point)}
+
${formatDateHHMMSSFromMilliseconds(item.duration_ms || 0)}
+ `)} +
+
+
`; + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return [ + sharedStylesGrid, + sharedStylesMediaInfo, + sharedStylesFavActions, + css` + + .show-info-grid { + grid-template-columns: auto auto; + justify-content: left; + } + + .show-actions-container { + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + } + + /* style episodes container and grid */ + .episodes-grid { + grid-template-columns: 40px auto auto auto; + margin-top: 1.0rem; + } + + /* style ha-icon-button controls in tracks grid: icon size, title text */ + .episodes-grid > ha-icon-button[slot="icon-button"] { + --mdc-icon-button-size: 24px; + --mdc-icon-size: 20px; + vertical-align: top; + padding: 0px; + } + + ` + ]; + } + + + /** + * 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; + } + + // show progress indicator. + this.progressShow(); + + // call service based on requested action, and refresh affected action component. + if (action == Actions.ShowFavoriteAdd) { + + await this.spotifyPlusService.SaveShowFavorites(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.ShowFavoriteUpdate]); + + } else if (action == Actions.ShowFavoriteRemove) { + + await this.spotifyPlusService.RemoveShowFavorites(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.ShowFavoriteUpdate]); + + } else { + + // no action selected - hide progress indicator. + this.progressHide(); + + } + + return true; + } + + + /** + * 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.ShowEpisodesUpdate) != -1) || (updateActions.length == 0)) { + + // create promise - get action list data. + const promiseGetShowEpisodes = new Promise((resolve, reject) => { + + const market = null; + const limit_total = 200; + + // call service to retrieve show episodes. + this.spotifyPlusService.GetShowEpisodes(player.id, this.mediaItem.id, 0, 0, market, limit_total) + .then(episodes => { + + // stash the result into state, and resolve the promise. + this.showEpisodes = episodes + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.showEpisodes = undefined; + this.alertErrorSet("Get Show Episodes failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseGetShowEpisodes); + } + + // was this action chosen to be updated? + if ((updateActions.indexOf(Actions.ShowFavoriteUpdate) != -1) || (updateActions.length == 0)) { + + // create promise - check favorite status. + const promiseCheckShowFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckShowFavorites(player.id, this.mediaItem.id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isShowFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isShowFavorite = undefined; + this.alertErrorSet("Check Show Favorites failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckShowFavorites); + } + + // 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("Show actions refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} + +customElements.define('spc-show-actions', ShowActions); diff --git a/src/components/track-actions.ts b/src/components/track-actions.ts new file mode 100644 index 0000000..25f511d --- /dev/null +++ b/src/components/track-actions.ts @@ -0,0 +1,462 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { + mdiAccountMusic, + mdiAlbum, + mdiHeart, + mdiHeartOutline, + mdiMusic, +} from '@mdi/js'; + +// our imports. +import { sharedStylesGrid } from '../styles/shared-styles-grid.js'; +import { sharedStylesMediaInfo } from '../styles/shared-styles-media-info.js'; +import { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; +import { FavActionsBase } from './fav-actions-base'; +import { Section } from '../types/section'; +import { MediaPlayer } from '../model/media-player'; +import { formatDateHHMMSSFromMilliseconds } from '../utils/utils'; +import { ITrack } from '../types/spotifyplus/track'; +import { openWindowNewTab } from '../utils/media-browser-utils.js'; + +/** + * Track actions. + */ +enum Actions { + AlbumFavoriteAdd = "AlbumFavoriteAdd", + AlbumFavoriteRemove = "AlbumFavoriteRemove", + AlbumFavoriteUpdate = "AlbumFavoriteUpdate", + ArtistFavoriteAdd = "ArtistFavoriteAdd", + ArtistFavoriteRemove = "ArtistFavoriteRemove", + ArtistFavoriteUpdate = "ArtistFavoriteUpdate", + TrackFavoriteAdd = "TrackFavoriteAdd", + TrackFavoriteRemove = "TrackFavoriteRemove", + TrackFavoriteUpdate = "TrackFavoriteUpdate", +} + + +class TrackActions extends FavActionsBase { + + // public state properties. + @property({ attribute: false }) mediaItem!: ITrack; + + // private state properties. + @state() private isAlbumFavorite?: boolean; + @state() private isArtistFavorite?: boolean; + @state() private isTrackFavorite?: boolean; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.TRACK_FAVORITES); + + } + + + /** + * 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(); + + // define actions. + const actionArtistFavoriteAdd = html` +
+ this.onClickAction(Actions.ArtistFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionArtistFavoriteRemove = html` +
+ this.onClickAction(Actions.ArtistFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + const actionAlbumFavoriteAdd = html` +
+ this.onClickAction(Actions.AlbumFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionAlbumFavoriteRemove = html` +
+ this.onClickAction(Actions.AlbumFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + const actionTrackFavoriteAdd = html` +
+ this.onClickAction(Actions.TrackFavoriteAdd)} + slot="icon-button-small" + > +
+ `; + + const actionTrackFavoriteRemove = html` +
+ this.onClickAction(Actions.TrackFavoriteRemove)} + slot="icon-button-small-selected" + > +
+ `; + + // define supporting icons. + const iconArtist = html` +
+ openWindowNewTab(this.mediaItem.artists[0].external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + const iconAlbum = html` +
+ openWindowNewTab(this.mediaItem.album.external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + const iconTrack = html` +
+ openWindowNewTab(this.mediaItem.external_urls.spotify || "")} + slot="icon-button-small" + > +
+ `; + + // render html. + // note that mediaItem could be an IAlbum or IAlbumSimplified object. + return html` +
+ ${this.alertError ? html`${this.alertError}` : ""} +
+
+
+
+ ${iconTrack} + ${this.mediaItem.name} + ${(this.isTrackFavorite ? actionTrackFavoriteRemove : actionTrackFavoriteAdd)} +
+
+ ${iconAlbum} + ${this.mediaItem.album.name} + ${(this.isAlbumFavorite ? actionAlbumFavoriteRemove : actionAlbumFavoriteAdd)} +
+
+ ${iconArtist} + ${this.mediaItem.artists[0].name} + ${(this.isArtistFavorite ? actionArtistFavoriteRemove : actionArtistFavoriteAdd)} +
+
+
Track #
+
${this.mediaItem.track_number || "unknown"}
+
 
+
Duration
+
${formatDateHHMMSSFromMilliseconds(this.mediaItem.duration_ms || 0)}
+
 
+
Released
+
${this.mediaItem.album.release_date}
+ +
Disc #
+
${this.mediaItem.disc_number || "unknown"}
+
 
+
Explicit
+
${this.mediaItem.explicit || false}
+
 
+
Links
+
+ openWindowNewTab(this.mediaItem.artists[0].external_urls.spotify || "")} + slot="media-info-icon-link-s" + > + openWindowNewTab(this.mediaItem.album.external_urls.spotify || "")} + slot="media-info-icon-link-s" + > + openWindowNewTab(this.mediaItem.external_urls.spotify || "")} + slot="media-info-icon-link-s" + > +
+ +
+
+
+
`; + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return [ + sharedStylesGrid, + sharedStylesMediaInfo, + sharedStylesFavActions, + css` + + .track-info-grid { + grid-template-columns: auto auto 30px auto auto 30px auto auto; + justify-content: left; + } + + .track-actions-container { + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + } + ` + ]; + } + + + /** + * 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; + } + + // show progress indicator. + this.progressShow(); + + // call service based on requested action, and refresh affected action component. + if (action == Actions.AlbumFavoriteAdd) { + + await this.spotifyPlusService.SaveAlbumFavorites(this.player.id, this.mediaItem.album.id); + this.updateActions(this.player, [Actions.AlbumFavoriteUpdate]); + + } else if (action == Actions.AlbumFavoriteRemove) { + + await this.spotifyPlusService.RemoveAlbumFavorites(this.player.id, this.mediaItem.album.id); + this.updateActions(this.player, [Actions.AlbumFavoriteUpdate]); + + } else if (action == Actions.ArtistFavoriteAdd) { + + await this.spotifyPlusService.FollowArtists(this.player.id, this.mediaItem.artists[0].id); + this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); + + } else if (action == Actions.ArtistFavoriteRemove) { + + await this.spotifyPlusService.UnfollowArtists(this.player.id, this.mediaItem.artists[0].id); + this.updateActions(this.player, [Actions.ArtistFavoriteUpdate]); + + } else if (action == Actions.TrackFavoriteAdd) { + + await this.spotifyPlusService.SaveTrackFavorites(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.TrackFavoriteUpdate]); + + } else if (action == Actions.TrackFavoriteRemove) { + + await this.spotifyPlusService.RemoveTrackFavorites(this.player.id, this.mediaItem.id); + this.updateActions(this.player, [Actions.TrackFavoriteUpdate]); + + } else { + + // no action selected - hide progress indicator. + this.progressHide(); + + } + + return true; + } + + + /** + * 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.AlbumFavoriteUpdate) != -1) || (updateActions.length == 0)) { + + // create promise - check favorite status. + const promiseCheckAlbumFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckAlbumFavorites(player.id, this.mediaItem.album.id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isAlbumFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isAlbumFavorite = undefined; + this.alertErrorSet("Check Album Favorite failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckAlbumFavorites); + } + + // was this action chosen to be updated? + if ((updateActions.indexOf(Actions.ArtistFavoriteUpdate) != -1) || (updateActions.length == 0)) { + + // create promise - check favorite status. + const promiseCheckArtistFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckArtistsFollowing(player.id, this.mediaItem.artists[0].id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isArtistFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isArtistFavorite = undefined; + this.alertErrorSet("Check Artist Following failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckArtistFavorites); + } + + // was this action chosen to be updated? + if ((updateActions.indexOf(Actions.TrackFavoriteUpdate) != -1) || (updateActions.length == 0)) { + + // create promise - check favorite status. + const promiseCheckTrackFavorites = new Promise((resolve, reject) => { + + // call service to retrieve favorite setting. + this.spotifyPlusService.CheckTrackFavorites(player.id, this.mediaItem.id) + .then(result => { + + // load results, and resolve the promise. + // only 1 result is returned, so just take the first key value. + this.isTrackFavorite = result[Object.keys(result)[0]]; + resolve(true); + + }) + .catch(error => { + + // clear results, and reject the promise. + this.isTrackFavorite = undefined; + this.alertErrorSet("Check Track Favorites failed: \n" + (error as Error).message); + reject(error); + + }) + }); + + promiseRequests.push(promiseCheckTrackFavorites); + } + + // 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("Track actions refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} + +customElements.define('spc-track-actions', TrackActions); diff --git a/src/components/userpreset-actions.ts b/src/components/userpreset-actions.ts new file mode 100644 index 0000000..d6890c3 --- /dev/null +++ b/src/components/userpreset-actions.ts @@ -0,0 +1,135 @@ +// lovelace card imports. +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 { sharedStylesFavActions } from '../styles/shared-styles-fav-actions.js'; +import { FavActionsBase } from './fav-actions-base'; +import { Section } from '../types/section'; +import { MediaPlayer } from '../model/media-player'; +import { IUserPreset } from '../types/spotifyplus/user-preset'; + + +class UserPresetActions extends FavActionsBase { + + // public state properties. + @property({ attribute: false }) mediaItem!: IUserPreset; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.USERPRESETS); + + } + + + /** + * 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(); + + // render html. + return html` +
+ ${this.alertError ? html`${this.alertError}` : ""} +
+
+
+
+
Name
+
${this.mediaItem.name}
+ +
Sub-Title
+
${this.mediaItem.subtitle}
+ +
Type
+
${this.mediaItem.type}
+ +
URI
+
${this.mediaItem.uri}
+ +
Origin
+
${this.mediaItem.origin}
+ +
+
+
+
`; + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return [ + sharedStylesGrid, + sharedStylesMediaInfo, + sharedStylesFavActions, + css` + + .userpreset-info-grid { + grid-template-columns: auto auto; + justify-content: left; + } + + .userpreset-actions-container { + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + } + ` + ]; + } + + + /** + * 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 { + + // no actions to update for this media type. + return true; + + } + catch (error) { + + // clear the progress indicator and set alert error message. + this.progressHide(); + this.alertErrorSet("UserPreset actions refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} + +customElements.define('spc-userpreset-actions', UserPresetActions); diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..c3385e9 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,46 @@ +import { css } from 'lit'; + +/** current version of the card. */ +export const CARD_VERSION = '1.0.1'; + +/** SpotifyPlus integration domain identifier. */ +export const DOMAIN_SPOTIFYPLUS = 'spotifyplus'; + +/** media_player integration domain identifier. */ +export const DOMAIN_MEDIA_PLAYER = 'media_player'; + +/** debug application name. */ +export const DEBUG_APP_NAME = 'spotifyplus-card'; + +/** prefix used for event dispatching to make them unique throughtout the system. */ +const dispatchPrefix = 'spc-dispatch-event-'; + +/** uniquely identifies the configuration updated event. */ +export const CONFIG_UPDATED = dispatchPrefix + 'config-updated'; + +/** identifies the media browser refresh event. */ +export const MEDIA_BROWSER_REFRESH = 'media-browser-refresh'; + +/** identifies the item selected event. */ +export const ITEM_SELECTED = 'item-selected'; + +/** identifies the item selected event. */ +export const ITEM_SELECTED_WITH_HOLD = 'item-selected-with-hold'; + +/** identifies the show section event. */ +export const SHOW_SECTION = 'show-section'; + +/** Company branding logo image to display on the card picker */ +export const BRAND_LOGO_IMAGE_BASE64 = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAsUAAALFCAYAAAAry54YAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAP6dJREFUeNrs3ct1G8fWBtAyl+emIyDuzDPREQiOQLwRCIxAVASCIjAdAaEITEVgMIJLzjQzGMEvRaC/yqgWWzQfePS7916rF2TJsqVCo/vrg1NVP3z9+jUAAMCYHRgCAACEYgAAEIoBAEAoBgCAUfvREABs5ujT6SS+TO799PSRf/2hf7cKy0d+fpWPb25/uVh61wA284PVJ4ARh9zpAwG2/OPDeLwYyF/3SzyuHwnXxY8/xyB97cwAhGKAYYTdFGaP8z9O772mwHtklJ51lV9X9w7BGRCKAToUfCc54Kbwe1gKvS+NTiOKynMRlq9zYF4aGkAoBqg+/E7DXUvDNAyrpWEsgTkF5VUMzCtDAwjFAE+H31TxPS6F3/SqzWF4ru6F5esYlj8bFkAoBsYYgKc5ABeHyu+4FZXlZX69VlUGhGJAAAZBGRCKgR4H4EkOvkUQNumNKt0WATmFZZP6AKEY6EoILgfg9KoHmKZdhbuK8lJ/MiAUA02G4OL4yajQMTdFQBaSAaEYEIJBSAaEYmDHEJzWAD4phWDtEAzNVQ7Il3bmA4RioByEj3MQToeVIRiTL0VADusq8sqQgFAMjCcEl6vB6VVLBKzd5ICsigxCMTDQIDwJd9Vgy6TB89Lyb8sckC8NBwjFQH+DsLYIqEa5zeLSZD0QioF+BOFZDsImyUE9PgrIIBQDgjAgIINQDHQiCE/iy5kgDAIyIBTDGINwCsGzoEcYuuxLKRybpAdCMVBBEC6WT0vHKyMCvQzIi3RY5g2EYmD7MDwNd33C1hGGYbgpBWTtFSAUA48E4UkOwalXWJ8wDNvHHI61V4BQDOQwXPQJa4+A8UmbhCxyQF4ZDhCKYWxBeJKDcDpUhYFE9RiEYhhNGJ6GdXuEqjDwGNVjEIphkEE4rSAxC3qFge19yOF4aShAKIa+huFJfJkHK0gA+0srV5zHcLwwFCAUQ1/C8DSH4ZdGA6hYWvf4PGitAKEYOhyGZzkMa5EAmpBaK85tCgJCMXQhCKd+4bN8aJEA2nCVHsj1HYNQDG2E4UkOwjNhGOgIfccgFEOjYXgej9dGA+iotKTbXDgGoRjqCMPTsK4KC8NAXxST8lL1+LPhAKEY9g3D82AlCUA4BqEYhGEA4RiEYhCGAYRjEIpBGAYQjkEohqGG4Um+KbwyGsCYw3EMxnNDgVAM4wzD6QZgNQmANUu5IRTDiMLwYQ7Db4wGgHAMQjFjDMO2YwbYnO2jEYphYIF4FtZ9w8IwwG7heBbD8cpQIBRDP8PwNL4s0g+NBsDePsTjzEoVCMXQnzA8yWHY8moA1bJSBUIx9CAMm0QH0Iw0GS9VjS8NBUIxdCsQn+VArG8YoDn6jRGKoSNheBrWk+heGA2A1vwR1itV6DdGKIaGw/BhDsM23wDohtRvfGZ9Y4RiaC4Qa5UA6K6rHI6vDQVCMdQThqdBqwRAX2ipQCiGisOwVSUA+skqFQjFUFEgPgnrNYe1SgD018ccjleGAqEYtgvDk2ADDoAhSRPxUjvFuaFAKIbNArGJdADDZSIeQjE8E4aPw7o6bCIdwPC9t100QjH8OxCnC+M7IwEwKjdhvSOeqjFCMaMPw6rDAKgaIxQz6kCcLoCqwwAkqsYIxYwuDKsOA/AYVWOEYkYRiNPKEr8bCQCeoGqMUMxgw/AkWHcYgO28ta4xQjFDCsR2pQNgV2ld45nd8BCK6XMYPsxh+JXRAGAPX3IwvjQUCMX0LRBPcyA+MhoAVORDWO+G99lQULUDQ0ANgXgeX/4SiAGo2Ot4XOdVjKBSKsVUGYYnwWQ6AJphEh5CMZ0MxCbTAdC0j2Hda6ydgr1pn6CKQJye1P8UiAFoWJrIrZ2CSqgUs08YnsSXNBPYznQAtE07BUIxrQRi7RIAdI12CnamfYJdAvE8aJcAoHu0U7AzlWK2CcNpM47ULmF1CQC67vT2l4uFYUAopupAfJwDsbWHAegLm30gFFNpIJ7FlwsjAUAP3cTjJAbjlaHgKXqKeS4QLwRiAHosrZCU+oynhoKnqBTzWBhO/cPLYLk1AIbDsm0IxWwViI9zILa6BABD8yEG45lhQCjmuUCcLhTnAjEAA5b6jKcm4FGmp5hyIE5h+EIgBmDgUmvgynrGlKkUU/QPL8J60XMAGIsvYb1k28JQIBQLxCbUATB2JuAhFI88EJtQBwBrJuAJxULxSANx+uCbUAcAd0zAGzET7cYbiE2oA4DvpVbCZbxPTgzF+KgUjy8QL+LLayMBAI9KE/BSxfjaUAjFDC8Mpwl15wIxAGwcjGcxGF8aCqGYYQXiZbDCBABs69SSbeOgp3j4gXgiEAPAzi7ivXRuGIZPpXjYgdiSawBQDUu2DZxKsUAMADzvdZ6szkCpFA8zEJ+E9bbNAjEAVMtaxgOlUjy8QDyLL38KxABQi2It40NDIRTT7UB8YSQAQDBGKBaIAYAmgvF1nsPDAOgpHkYgXgSbcgBAG+x+NxAqxQIxALC7NIdnqWIsFCMQA4BgLBgLxQjEAIBgLBQjEAMAgrFQjEAMAAjGQjECMQAgGAvFCMQAgGAsFCMQAwCCsVCMQAwACMZCMQIxACAYC8UIxACAYCwUIxADAIJxT/3w9etXo9CtQDyLLxdGAgAG60s8jm9/uVgZiu5QKRaIAYBmpYrxZbzvHxoKoRiBGADG7EVYt1IIxh2hfaIbgfgkvvxpJABgdG7iMb395eKzoWiXSnH7gTg12y+MBACMUqoYXxoGoVggDmEZ1r1FAMA4vcwrT9Ei7RPtBeJJfLkWiAGA7MPtLxczw9AOleJ2AnFqqr8UiAGAktd54j1C8WgC8TKse4gAAMouBGOheCzOBWIA4JlgbNc7oXi4bN8MAGzIdtANM9GuuUA8CzbnAAA2l7aDnljDuBkqxQIxANBNaUK+Xe+E4sEE4vTVx7mRAAB2kOYhLQxD/bRP1BuI05PdKlh6DQDYjzWMa6ZSXG8gXgrEAEAFrGEsFPfWIlh6DQCoTlqqbWoYhOLeiCds6iF+ZSQAgIpdWqqtHnqKqw/Es2ClCQCgPjfxmFqqrVoqxdUGYitNAAB1syKFUNzpQGxiHQDQlFe5XROhuHMEYgCgSW+sSCEUd0o8IRfBShMAQPPOTbyrhol2+wfi9IRmYh0A0JbbeBybeLcfleL9AvGxQAwAtB1J4nFpGITitgLxoRMQAOiIlzGbzA2DUNyGy/xkBgDQBe9iMD4xDEJxY/KT2EsjAQB0zCLmlIlhEIqbCMTpCeydkQAAOigtD6u9UyiuPRCnJ6+FkQAAOuxFXi4Wobg2l8EGHQBA9722sYdQXIu8laINOgCAvrCxh1BceSBOfcRvjAQA0CPp2+2FYRCKqwrEEycUANBTL/K33QjFe1sEfcQAQH+9sX6xULwX6xEDAANh/WKheOdAPA3WIwYAhkF/sVC8UyA+dOIAAAPzMn8LjlC8sRSIjwwDADAw7yzTJhRvJDeivzISAMBAXeZvxRGKHw3Ek6BtAgAYeOSJx9wwCMVPSYHY8msAwNBZpk0ofuSR6dPpWbD8GgAwHgttFELx/UCcGs5/NxIAwIhYpk0o/veTkiEAAEbolTYKofgfeb2+F04FAGCktFGMPRTntgm71gEAY6aNIqgUL3wOAAC0UYw2FGubAAD4zqjbKEYZirVNAAD8y6jbKMZaKV447wEA/mW0bRSjC8V5kw5tEwAADxtlG8WoQnF8gyfBXt8AAE9JbRTnQvHAn3zyGw0AwONeH306nQrFA5T7Y146xwEANjKqNopRhOL8hi6c2wAAm0eoeJwJxcMyD9omAAC29S4vZSsU9/4RZ90P88Y5DQCwk1FMujvwRgIA8ISXR59OZ0Jxj1mTGACgEudDn3Q32FCc37i5cxgAYG8/DT1XDblSfB5MrgMAqMqbIU+6G2QozpPrXjt3AQAqNdi5WgfeMAAANjTYSXeDC8Um1wEA1GqQk+4GFYpNrgMAqF2aszW4ne6GVimeB5PrAADqlna6mwjFHZTfGDvXAQA0Y1BzuIZUKV44NwEAGvMqr/glFHdFfkNeOjcBABo1mGrxUCrFC+ckAEDjXgxlibbeh+L8Rhw5JwEAWjEfwhJtvQ7F+Q2wUQcAQIuRLAxgiba+V4rTG2AJNgCAljNZ36vFvQ3FeeDPnIMAAK1LRcpef3vf50rxPKgSAwB0xes+b+jxYx//0DbqgNbcxmP1wM9fx+PzI79n9cjvecjn218urmu4Xmx6kU7fQB0/8evTR37PC6cGwD/m8Zj18Q/+w9evX/sYihfpacR5B1u7eiKs3v/nygPqWOT2rvvh+jgH6IcCtmANDMmvfbx/9C4U56rP3843CF/CukJ7P9CWfyzY9jNUlwP0JNxVuu//2HKUQBddxXvPVCiu/2ZxGV9eOd8YsHKLwvJ+0I0XmqUh4okQPX0gQNvxE2jab327X/UqFOftnP9yntFj5eru8t7rdbyAfDZENBCey+0dU8EZqEHvqsV9C8VLF276cCEI60ln1+VXbQz0LDg/9GrFH2AbvaoW9yYUqxLTIUW1d5WPb+FXpZcRhuZpMFEQeFivqsV9CsXpSUOVmCbd5LC7LAdgwRcevU5Pwl0v80RgBkKPqsW9CMWqxDQQfv8JvPlYaXWA2gJzEZSPg5YMGIPeVIv7EorTE4YqMfu6LQVf4Re6cX0vAnI5NKdXy83BcPSiWtz5UKxKzK5PpqFU/bWMGfQyME9LgVllGXp8T+5DtbgPoTiFGVVingvA16UArPoLww3Kk3BXUS4Cs55l6L7OV4s7HYpViXlA6v9dCsDAA/eLclVZMQW6pfPV4q6H4kV8ee08Gq2iB3gZtEAA299DipaLY0EZOqHT1eLOhuL8Fdnfzp9RKarARQheGRKgxqA8DVovoEmdrhZ3ORQvgirx4D8cRQhWBQZavN9MSyE5vVr5Aurzn64WvToZilWJhWCAlu9B5ZCs7QKq8yFmgJlQvPkF6Ty+vHHeCMEAHbkvTUtBOR2WhoPddbJa3LlQnBdyX7ng9NJNKQRfGg5gwCH5+F5I1nIBm+tktbiLoXgeX945X3rhSzwuS0F4ZUiAkYbkSSkgC8nwfH6YxNzwWSh++sKSBkiVuLtuchC+tEYwgJAMO3ofc8RcKH78IjKLLxfOk849zS1zEFYNBtg/JJ8ExR/4EjPFoVD8+EVj5Wm6Mz6EdTVYbzBA9fe7cj/yKyPCSJ3GnLEQiv99gUgXBls6t/zUFo+08sd51/p8AAYektM98CTYUIRxuY15YyIU//uCsAzWghSGAQTkw1JA1mrB0HVm6+dOhGKbdbTqYzxmwjBAZ0PycQ7H6VBFZnA5JGaQE6H47gO/CLZ0btqXHIb1DAP0JyBPwl0FWS8yQ9GJzTxaD8U262hFWlZtqjoM0PuQXFSQU1A2UZ2++iNmkjOh+NNpGoTfnQ+N6eye4wDsdT9NbRazYLIe/dOJzTy6EIpXnm4FYgAqvbdOwrqCPBOQ6YnWl2drNRRbhk0gBqCxgJzuufqQ6aqbmFOOxxyKL31ABWIAGrvvFsu9mahHF7W6PFtrodgybI25iifY1DAAICDTca0W8doMxfP48s77X6vbeBxbZQKAZ+7Jk6AHmW74ua3c0mYoXgUT7OrWmV1iABCQYQNvY3Y5H00ozusq/ul9r1Un1vwDoNcBuVjmLd23FbJowm3ML5MxhWIT7Go+oYK2CQCqvXcX/cfpsOEWdWrlm+7GQ7EJdo1ofa0/AAYbjosJerN4vDQi1KCVCXdthGI72NWrta8dABhdQJ7kcJwO7RVUqfEJdwct/CX1udZrbggAaEIMLat4zHMx5rd4fDAqVOSk6f9ho5ViO9jVf31SJQagTaX2ilQEs3oFu2p8h7umK8Uz73GtFoYAgDalr7zTvJYcaH4N6+rxFyPDll7k9pzhheLSkyNCMQDjCMjXecJUCjen8bgxKmyh0ZbbJivFlnCpV/qaYWUYAOhgOC5Xj/Ues012HGwopj6XhgCAHgTkZa4e/xyP92G9tj485Civjz2cUJx7QmzWUa+lIQCgR+H4c2nlitRacWVUeMCwQnFQJW7kydsoANDTe1hqrZiGu4l58C1D5nlpgwnFM+9prUxcAGAI4fj6XmuFVStI89EaKa7WHopz64R1Cuu1MgQADCgcF60VqUKYWiv0HY/bMEJxsINdE64NAQADDciL0o55+o7H6VUTLRRNhGL9xADAvuF4qe941GrPk7WG4pjq03qER97H2qkUAzCWcFz0Hf9HOBaKexOKgwl2TflsCAAYWThemZQ3Kq/q3va57lCsdQIAqDMc/zMpL6y3khaOh63WXFlbKNY6AQAIx1Ro1stQHLRONGlqCABAOB64F3WuQlFnKNY6AQAIx/QiX9YSirVONG5iCABAOBaKOxaKgyqxUAwAwjHVq20jD6F4GF4aAgAQjkeilpxZeSjOa8i98H41K7esAADbh2ObgAjFtVSKVYnbMTUEALBTOJ4FO+SNPvMIxZ6aAEA4vtsh77d4XBmRTvvp6NNp5bnnh69fv1b2H8uNz//nvWrNz+mJ1zAAwN6ZZhpfFsFqWl31IT/EVKbqSrFqZbuMPwBUIAauZTwm8Ydvg8l4XTSt+j9YdaU4PVG99j615iZ+gE24o1b5G6HjLS5Qj/37D6lrJZXbeKw2+PfSNy3Xj/za6qH/RrpxOitgFNe9s3i8Mxqd8mu8Bl93NRSnG8pP3qNW/eYmzQaf1XKAnYTv17q+H26Pfa638uWBYL18Inhfa3uC3lw707VyESyF2hVv4/XzvHOhOC8J9j/vT+uu4gkyNQyjvFBP7oXacoU2/dhSid1XrmivHvmxEA3dKCykcKzfuF2VfkNeZSieB18rdIVq8XAuvOVgW4TdIgALuuNWrkgXoblchRaeof5rdMo+qa3Ct2ntqWyRgSpD8bUbdGeoFvfngjophdyJwEsNbu8F5m/B2cMzVHYdT1/hvzIarfhvvJZddiYUW4qtkyrts2Gvz8dxDrnTcNfSkC6ivnajMw/S+XVZDs9VTmCBEVzrU8X4dyPRuMqWZqsqFKc/zIX3pVPSV6vHaTFyQ9FYpWAi+DJARaW5OFJQXgnM8OC94Dg/XGqnaPAalZfO60woXgRLsXWRJdrqueBNcugtKsBmITP2wFy0ZSxzYPYwzpjvE4f5s6AFrjmVLM1WVShOF0AVsW6qfMeXkVzUJuGu8luEYBc42NxVUF1GMHbfaEYlLaN7h2JLsfXCaTxZFobhyXP4uBSCrcsL9bkpQnJQWUYwphof43Vk7119qwjFGssF4z4G4OLQ+gDtK5aXK46VlTEYUDBOD30KLTVfQ+I147ALoTgtg2EZEsG4ixejSSn8TgVg6J2be2HZ2sv08V7kG/Vm7L1HQxWh2NbOgnFXLjzTUgBOr/rcYXhuSyF5qaJMT+5PvlWv3/t4PZi3Foo9/fRW7yff5a+kyiFYFRjG6+ZeUDahjy7et5buVbXae+OyfUOxJ59+30RO+jLBpRSCi8PEBeDJG2QRksO67WJlSGj5PjbJ56Rv12sSP+c/tBmK9RP3W5rcMu/qznfx/DoRgoGq7pdFQA6qybR3X5vHl3dGojZ79RXvG4r1Ew/nZjFruzcvt+MUQdhXTEDdRYGikqw3mabuc4f5vDPnpR579RXvHIr1Ew9S+rrxPJ5Qlw1eHIoQfOIBC+jANVBIpu573yy+XBiJej7D+/QV7xOK9RMPV6ocp5aKy6r78ErV4HRoiQCEZMYWiq1dXJ+91iveJxQv4str4z94aULe5a43hdJawUVF2FdGQN9D8qWeZPYMxqnw9MZI1OLXXT+f+4TilYAz2pD8Od8YHjPJh+2SgaH6kq+DRUheGRK2yFDpHvm3kajF210XENgpFHszAeA7qe3sMty1W9h5j+eyVKpmaiOs3sf4+TvZ5Tf+uOP/8NiYA8Bdxgnrr8Pf5MBzFe5az7Ra8JBLobgWO2fUXSvFemEAYDO3pYB8aTjIWcoqXvX5zy4tTbuG4mWwjiwAbKvoRU7h+FKbxeiD8VejUIv/7vIAerDj/0wgBoDtpcnHaSfYtE7t/6W+0rTEaZ6rw/hcGYJa7NRCsXUojh/cqbEGgEqkntK05v/fOSCf56/VGQf95vXYKavuMtHOhxUA6gnI6XgTg3HRh7wwUW/QtM/UY6eOhl3aJ4RiAKhXsZrF/9K+ACrIg7U0BDV9gHb4vAjFACAgw9Bs/RnZpX3CmnoA0G5ALlos0hKpdtSDpkOxSXYA0KmAnCbp/R7vzzfxdRHWPcj6VGGHUHxQ9/8AAKhdsYpFWubtMh4zQ8LIbT3ZTigGgGH5Zx3kGIw/x2Oh/5ix2vbcF4oBYJjSRiGvw90EvbRJyKFhYUQmdYZik+wAoH+K/uOiveLEkHSCYmOHxnfjiXa+fgGAQUjtFa/y6hWLsJ6ctzIsrVC5r9d0m395m0qxUAwAw5Gqx+/Ceotp1eMehDa2VltP8cTYAsAgperxn3qPux3a2NpP25zL24RiTzMAMGzl3mMrV9Q50J9OJ2E9GZKOPHioFAMADylWrlha97gW2lU6Foq32dHuyLgCwOikTRBexmA8D+uJeed2zavE1BA0YrLpv7hRpdj2zgAwesXEvFVurZgYkh0Hct3n+spINKLy9gknPgCQFJuCFKtWTA3J1maGQCgGAIYjVTv/0ne8tTND0NxD3KYrUGwaij0FAgCPSX3HF3lJN+H4CXl8zNNq1kbV4k1DsfUKAYBnM185HFvv+EFzQ9C4SZWh+IXxBAC2CcdhPSlvLhznQVEl7ncoNrsUANhRmpT3Tjj+tuLEuVOiFZW1TwjFAIBwvJ95sINdWzbKspuEYls8AgDC8Y7ysnVvvP2t2agNeJNQrA8IAKgjHF8PfbWKHPwvveWdeB/2DsVTQwkA1JFVwoCXcstBbBm0TXTBs50PB8YIAOhQOJ4O6O+VJtZZwasbJlWE4pfGEQBoKBwXO+T1OhzHP/8irLfDpieh+Mdn3lD9xEDZbTxW937uczyun/g9y5Yufo9dANN17aGv0dLP+YoTuuFlDscf4uv89peLlUBMq6E4WHkChh5sV/dC7ncBNt6IlmMdpFwUOH4iaB+Hu4nI6ectyA/VS8Hydfw8vo+v5/Ga9LkH14103dQy0cNQ/MPXr1+fenOn6UnNOEKvgm4RZMsV3FXfKi09DtPl8FwOzlMBGvbyJR5n8Vq26OhnP33eL32+u3ufjOfOZJ9QPA/rJVOAdm8E1/dCbhF8r7teOeHJm+j0XnguAnX6sUoTPOwqrFsqlh36LMtLfUjFv1z8IBRDPy7yn++FX9VdofmwFJjvvwrNjF3qNz5rszCQH2ytMNEfPz91vjwXitNTmNUnoPrg+0/4HXPPLpXckCfhrro8KQVm123G4ksOpY32G+fPXvr/vvIW9MpvT913hWKo/gJdhN5V8WMtDrQYmI/vvep3ZKjX3kUOx6saP1cn8eVMNhpnKF65gILwy+AC8zTcVZenwjIDc5MD8mUVATlPoJvF48TnpPfex3Nivmso/mr84J+VHYoAnJ4w9foy5LBcrixbu5khXL+XpWv4k8WLHIAP88PicX71GRCKhWJGW2G43vQCCiMIyuWAXByqZQxB+sZvFUySG5MP8Z4+2zoUW6OYkVQQyhVgARg2C8rFKhhTQRnokat4n58+9os/Gh/G9GEIpa/RtEDAjk+T64fHZSjtgPhAUE6vvnYGeuOpUDwxPPTYl9JN+9rSZ9BKUJ7cC8pm7ANteikUM4p7cumGvFQFhk4E5fQ5TMdlKShPS0E5HarJQCdon0AIBpoMysXn9jyH5EkpIKdDbzLQiqcm2i3iy2tDREekdohLIRiGLfcml0OylQGAKj26gYf2CbrsYykEXxsOGL7cm3yZDyEZaIz2CbrkJofgSxPjgEdC8iRotwAaDsWHhocGfMw3Oy0RwCYhOV0nFvkoQvJJMHEP2Eya6LvcNhT7iopa7mmlEHxpOIAKQvJ5uJu4Ny2FZPcx4L5Hi77aJ2hCmiS3SIfeYKDmkLwMuQqU+5FPSiFZFRkIQjGt3J/iMY83qYWhAFoIyJ/D960W06CKDGP3aKX4wSXZco/W38aNHX3JYfjcUABddK8X+ZURgdG4ivlk+tAvPFYpnhgzdj3Z4jEzaQ7osnIvcqnNYppftVnACGmfoEpvVYeBHgbk+20WRR+ygAxCMWwltUucWFsYGEhILq+LXK4gWxMZhGJ4MhBPrSoBDDwgn8WAnNY3nQnI0GvH24biqTFDIAb4LiCna93ZvYCcDi0W0B8/bRuKYRMzgRgQkPUgwxAIxezqvR3pAB7sQZ4Fy7xB7xwYAnaQ1vibGwaAfwfkeKRg/HM8TsN6mUqgB1SK2VbqI54ZBoAnw/G3Zd7yRiGzfJigBx2lUsy2zm3MAbBVQF6lb9fikcLxr/H4ENYFBqAFeaLsxqF4Ysh4QLqI25wDYPeAfB2PWb7Paq+Adhw+9JO2eWYb5/krQQD2C8f32yvSShbWP4YWaZ9gU6rEAPUE5NRecZbbK/4bj49GBYRiuutSlRig9oBcrF7xn3i8D3qPQSime6HYEAA0Fo6LyXmp91HvMQjFdMQXG3UAtBaQF/GYhnX12MoVIBTToqUhAGg9HK/urVxxa1RAKKZZ14YAoDPh+HOuHqdw/FvQWgFCMY1ZGgKATgbkpdYKEIppzsoQAHQ6HJdbK9KqFVorQCimjoutUQDoxfX6c2lLaX3HIBQDwOgDsr5jEIoBgByOi77jFI4/GBEQigFg7OF4Fu4m5QFCMQCMNhyvhGMQigGA78Pxz2G9YoXl3BCK4SlHn04nRgFgsOH4nxUrwt1ybsIxQjE8QigGEI5BKGb0poYAQDgGoRihGIAxh2MT8hCKIXppCABGHY5nwWoVDNyPhoBNHH06PYkXxUsjwTPnySQ834M+rfh/u3zm11e2KodKwnH6HM3i53weX9Px2qgwhlCcbjKqg5SlKoFQPOxAWw6r98PtcTwOS/+cfvyiI3/0dxv83R766Zt4fH4iYKdfuxau4dFwvMjhWF5gEH74+vXrQzeQ+SY3GkbnP0JBr0LuYQ6z90PttIPBtm++lAJzOTyv8vHP7mGGiRE9UJ+7ntD3PCMUs433edIF3Qm85eA7LQXgn4xSZ9wWQTncVaKvizCd+jUNEQO5Ls3CunJ8ZDTo9EX5l4sfHvp5oZhtpOrYxE28sRvMtBR2yyFYNWa4wfn+ITTTxwf2s3x4OKdXodhEO7aRLnDpK7KZoajsBlIE3RR8J/lQ6R3hqZCPlw+cI+ml6H9ehrt2DT3OdDFspPNzHs/b83y/MBmP3lApZhe/6ZfcKfwWgbf4sYovVbgqBeUiLF8bFjp07TsPJuPRrYe3rdonZvHlwrDx2PmUgp2vdR+8AUxy4J0Kv3Tgc7oK6+qysEzb18aTHI71G9O7UJxu6H8ZNp7wIS/mPvYAXFR+ixCs7YGuuylCchGYPeDS4HVzHvQb0/I1MF7zjoViqvY2nljnAjD0XrHE3DK/XutXpubrabp3vDIatOAqXt+mQjF1OI0n12KAF+1pKfymw1d+CMqCMtVfZxeurwjFCMbduTAXqz8UIdiEEHjYbbib0Lc04ZaKrsHzoKWCrobifJJ+NW5soTcbe+Sv7qalQ5UCdpd6lJeloLwyJOx4XdZSQRM+xuvUiVBM7SdaPGZdm7QjBEOjimryModkK16wzfV6lsOxqjF1ebSIJxRTtS85GF+2eFE9vBeCLYkG7V4TlkIyW1zDUxtbuocoYNCZUPzZkxp7SFXjs6a+Ss0X0RSA01cieoJBSKbfwfgwnyOKGnQiFC+FCyrwIR7nVd/4StXgk3x4gIN+h+TLoCcZwZj6/fexb7OFYpqSJuMs0o1v15te7g0uQrBzE4bp9l5ItrGIYCwYU6XfHls5RyimzZveKr+G+ydoXhYwSa+TYIIcjNVVEZK1Wow2GB/nc8A3grQWiufx5Z2xA6BjD9SqyOMLxukbwj+NBBX4z2PfWAvFAPTVx3BXRV4ZjsEH4/QwZB1j9nuy/uXih8d+7UfDA0BPvcrH7zEwFZuILLRZDNYsrNvutFFQi4Mnfm1peADoiTQR6008/hcD8ioe5/krdwYit8vMjQT7nEZP/eJT7RPT+PKX8QOgx9KSb+lr98s2NxWiOumhJ5h4zW6u4nVg+tgvPlUpNoEBgL5LX7W/jsefaVOqeCxUkHvv3BBQh0crxflpzFbPAAyRCnJP5bWL/89IsINHd7NLDowPACNUriAXPcjHhqX7cm/xByNB1Z4LxVeGCICBS/2p5Ul687yDJt2lus8uVvuEYgAYW0BOa/T/HYPxdTxm+et6umVpCGg6FK+MHwAjlZZ5u4jH/5mg1y25heLGSLClJxeReG7zDqEYANb9x69jME7rnKav7s/tote6ZX5wgU0fpp7c2EelGAA2V/Qfp/aKZWqvMCStkVGolEoxAOzmZTrSyhXxdRFUj5tmO2+28eziEc9Vim3gAQBPS8u7qR5Dzz0Zip/rvQAAvpOqxxd59zxLu9VL4Y5tLPcKxdkX4wgAW0nV42Jpt8t4TA1JtRTuqNomodhJBwC7exWPv/LGIDPDUQ07ELKlZRWheGUcAWD/HBe0VlTJpips49l2G6EYAJpVbq1YqHhC/TZpt9E+AQDtSZuC/C+vWjE1HFsxXmxqo/lxm4RiszsBoF5p1YrUd3yt73hjE0PAhjYq8D4bim9/uVgaSwBoRNq2+MKkvI1oO2FTq0pCcWZZNgBozlEpHKdJeSaVlQdnPR4vjARthGJ9xQDQTjhOk/KE4++dGAK2sKwyFK+MJwC05ifhWChmZxvNjxOKAUA47o28vvMrpwKb2nT3w01D8dKQAoBw3AEzbz9buNn0X1QpBoBhhOOzof9lc/g/87azhY0z7Eah+PaXC6EYALodjn8fwVJuZ/nvCpvaeLGIgy3+o1fGFQA6rbyU26Amo+XtsN95i+lCKF4ZVwDoTTj+c2DbRy+8rexg4/wqFAPAcBXbR1/mVRv6mfA/nc6DzTrYwaYrT2wbipeGFgB6KS1h9ncMl+d9W6kit4Fom2AXN9v8y9uEYrvaAUC/vQk9Wqki9xEvvG3saKvsunEovv3lIu0G8sX4AkCvlVeqmHY8EC+D1SbY3aqWULxL4gYAOitNxutkv7FATEWWdYbipfEFgEFJ/cbXeTJbFwLxTCCmIlsVc3/4+vXrNidqanb/0xgDwCDdxmN2+8vFsoUwnCYAnsfjtbeBKs7leB5PtvkN2icAgG/ZNLTQUpGLbtcCMRXaOrNuFYrzds8m2wHAsH1rqahzCbc00S9tMBLW30IfGXbaDMVbtU/kEzidvC+NNYze/a3fPz9yEVqFp2cAr/IDd9U323QjP37iX5nk477pA/+emzVj9yEe59tshPDMZzNVhtOycDbkoC6/bdsGtEsongeLaMNQw+3y3lP25+If2ugx7KI8K76onJWD9/0fu9kzRKnn+DJfK5Z5udZNPzfTfLwyjDTg503Pz31Cscl20H03OdCWq7ffQq6A20qILofm4ucmQRWa/ofkVfj3N0Xl8923yzR+Xm47yS75cYf/kcl20I3Au7p3CLtdvDJ//3Xz5SPBeZID8mEpMB8LzfThua90jqoA0xU7ZdWtK8X5Ar5yoYbaQ+/1/ddtvwpiIKnjLjQXgXkqMAM86m28X55v+5t+3PF/du1iDHu5KgXeVXHUMeGM/svnRTqWDwTm41JgLsKzr6uBMWu0UjwPJtvBs1mmFGSKAKzaSyNydfm4dKR/NvkPGEMh4Yddft+uleKlUAzf3OTwe52PVRXLFsGeN4VVPi8v74XlaSkkp1dVZWBIrnb9jTtVivOF9atxR/gVfum/3IJRPgRloK/+iPfms11+4497/E+vXDgZqC859C6LECz8MmT5/L6+F5SnpZCcfmweCdAHy11/4z6h+FooZgh5INxVfpdBzy8UQXlZvrnkXcimpZDs+g8MKhTv0z5hEw/65qYUgK+t6Qv7ydXkclD+yagAbT7P77JpR2GfSrFAQac/GPkcFYChrg/Zv6vJ5a18hWSgaXvd63euFOcLYAoclvihbV9KAXgZtEBAJ+SQfBK0WwDNOI33/8Wuv/nHPf/nS6GYFlzdC8ArQwLdc38CX267m+bDvQOo2nKf37xvpVhfMbXfV+8F4KUhgf7Lm4ukcFwEZa0WwF55YZ9+4qSKSjFULU2IW8TjUhUYBnr3Wn+2F/koJu2d5MPyb0DjmXSvSnG+kOkrpiof4nFuTWAYt1xFLgKyXmRgE3v1Eyc/VvCHWArF7Cn1CJ8Jw0CSq8jn6cjrIxcB+ZXRAZ7Io3upolKsr5h9vI03wHPDAGxwvyk2EClCsj5k4J9n6X37iZOqKsWwrbSM2lR1GNj4rrdeavEyH0VRRkAGLqv4j+xdKc4XphSM9X0hEAOtKAXk10YDRue/MVPsHYwPKvrDLL0fCMRAW9INMR6z+MOf43Eaj49GBUajkhxaVaU47Vr0P+8JG/hVIAaakHuQZ/kwIRyG6SrmimlnQnG++KReLz1dPMWkOqCtgDyJL2fBOsgwNO9jtph3LRSnXg7L5VD7kxzAnvcr/ccwHJV9A31Q4R/q0vvCE84MAdAF9/qP34b1LppA/3ypsiXzxwr/YEvvDY/4oI8Y6GA4Tm1/xSYhx+GuvUIrIPRDpQXZyirFeQciT9s8RB8x0PWAfJ2rx5OwXr3C/Qy6b9nJUFxHYmcQblSJgR6F48/xWMQjVY5/i8cHowKd1c1KsVDMIxaGAOhpQF6Weo/fp58yKtAZV7kFqpuhOFcEXTTwoAQMKRyn6vE8HpOwbq24MiowvHxxUMMfcul9oriX5F5zgKEE5EVeXvLXoLUChOKm/5D0lkAMDDUcFxPz/hPWrRVfjAo09xGso+hWeShO6z+6OJAtDQEw8HC8yrtpTcK6tUILIdSvlgLsQU1/WGEIgDGF42LViiIcW9IN6rPoUyjWQgHAWANyeUk3k/Kg4o9YXUu9CsUAUE84XuZJecIx9CBjHtR0IUjrxn30vgEgHH8Lx1asgP0texWK607yANDDcFxesUI4hu19yQs6CMX0ztQQAPwrHK+EY+hetjyo8UOvhYKJIQDYKBy7X8JQQ3ETf3g67+jo06lgDPB8OD4JJuTBU2ptnUh+bCAUX3gfRy1d6M8Nw4iehD6dTh/46cN4HD/zWyehmm8Xls/8evoW6/qBYLL07tFyOE7n4DR/hubxeGlU4LtMWasfvn79WvcNMv0lXnkvR+smr9dJf0Lt/QA7fSK4ph8fDTWjhO+3Kr/OgTrkny9+bVXHdqOQw/FiwJ8x2Mavda1P3GQongXV4rH7j9DQqaCbXg/vBd70zy+M1P4PgTk4l6vRRZgWntnnPjoXjhmx27xbZK2aCMXpZptuBD95T0frQ55MQv2Bd3rvdeJG2r2Le7irNH93CM0881lPwfjM/ZQR+iNeH896H4rzB3kRX157T0dNtXj/0PvQq+ru8BTV5mW4qzgLzJSvBykcvzEajEjtrRNNhuI02epP7+moXeUdnXg6+E7yUYReE2347nMU7irLS2F51NeMdJ1Ik5jN2WHoGmmdaCwU5w9wqnj4ymfc3sYTe9QrUeSJM0UAFnypOixfF0deK55xXFPSddW3RsgOPQrF6S/k6x4a+QqkIzeqST6KH+vtpUm3pZC8FJQHf82Z5XCs+MTQNNZ+2WQoTlWx/3lvR+9LColDCcb5vE6B91j4pW9B2drMgwvG+o0ZmkZbLxsLxfkDmy7EvuIhTSSa9SkYl3p+p6UQ7FxmKJ/HZSkorwzJIB7WU9VYaxZ9dxqvSYuhhuK0nMbv3mNChyvGeQLL8b1D9ZexKKrJyxySrw1Jb8PxLGipoN85YdJk21fToThV2/7P+0xJq5Pv7gXgaX51A4Hvb0xLIbm3wVhLBX3V+B4HjYbi/AG17TP3pZnzs7q/tr3XAlG8CsCwW0i+DNot+hSO0zVvEbR90R+NT8xvIxRbs5hHnwrjMa/qJptvAuUArAUCqnd7LyRb4aK7wTgVBtI3czbTovPXlabWJm41FOcP5kpA4QmpcrwIW1Sh8sW+HIBNMIH2Pr9FQNZq0c1wvBCM6bhWWivbCsXz+PLOe84mT4vhbgmpUHo9Lr2aCAfd/fwuU0iON7hLwyEYw4Z+buNbp7ZC8SS+/O09BxiNci/ypTYLwRge0fgEu1ZDcf4wmnAHMF4fg8l6bYfiw/ygYvIdXfJbWxsLtRmKTbgDIEkbiCzCuoIsIDd7L7bbLF3SygS71kNx/jCmi59eUADKAfk8aLFo8l6cxts6xnRBq3sXHLT8l194/wEoSV/lX8Tj/1KbXdqVLX/NT33mYd3zDW1rNRcKxQB01asckFdpUlhuu6NiuSJ/biRo2Ye2vx1qtX0iMfsVgG0yXFhP0DvXf1zpvXgSrApFu35te23zLoTiaXz5y7kAwJb0H1d7P06BxEoUtPJZjp/h47b/EG23T4S87MaN8wGALRX9x0V7xdSQ7GVhCGhJJ9p3Wq8U56fTWb6wAcA+bvMNdqF6vPW92PJstOFL/Kx2YjJtJ0Jx/jCmi9dPzg0AqrjRhrve42vDsfG9+KtRoGHv42d03oU/yEGHBsXMVwCqkoosaRL3/2LQW+ZvJHmedkZGm/+6FIoXzgsAavAyHhfpG8l4zK17/CQtJzTpQ5fanDoTivPSOh+cHwDUJFWP34X1xiCL3EMLtGfepT/MQccGRwsFAE0ot1ZMDQc07qpra413KhTnyRBXzhMAGpJaK/6KwXil7xgaNe/aH+jAIAFAOArrvuMUjs/0HUOtbvM+FULxk6NkMw8A2g3Hv4f1hiBjnJQ3cQrQgHkX/1CdWaf4uyuSzTwA6Ia03nGa73I+9M1A8gPA/3nLqVmqEnfy4eugk6P1y8UirHclAoA2FStWjKFyPPV204B5V/9gBwYNAIRjoZgGFDtNdlIn2ycKtn4GoMM390G1VaRJhmHdUw116cyWzg856PjgWbcYgC4aVOU4/vlPBGIaepDsrD6E4i/OIwB6EI5nPf57nHkrqTvTdf1blU6H4jx4qsUA9CEcX/RxE5C8o99LbyE16nyVuPOhuHiyCKrFAPQkY+Zw3KftoxWfqP0c60PvfedDsWoxAD1UbB+dwvGkswn+0+k8vrzwdlGjXlSJexGKS0+xqsUA9DEc/x3D53nXJuPFP89xWPdDQ60Zri8rtPQiFKsWA9Bzb8J6Ml4nJrTl6vXS20LNelMl7k0oLp40gmoxAP2VJuP9nifjTVsMxKlifRnsA0AD2a1P63j3JhSrFgMwEGkyXuo3vmy63zi3TFwHfcTUr1dV4l6F4uKJI6gWAzAMr1JAzZPdmgjEaYOOZbBJBw1ltr7t9tjpbZ4f+VCni4eJAQAMyW08ZjFELGu4b6Z2iXTvfGOYaUgqYE6E4maC8cqTLgADdBWPsxgmrisKw2f50D9Mk97Hc3jetz90X0PxLL5cOOcAGHA4XsTjcttqW+4bnuVDGKZp6VuP475ViXsbivOHfhVUiwEYR0BehvUEuRQ0rovAkQNwqggf52Pq3kjLTuP5uejjH7zPoTg9AasWAwB0w20MxJO+/uEPejvq66eQK+cfAEAnzPv8hz8w+AAA7Omqr20TgwjFeeka1WIAgHbN+/4XOBjAmzBzHgIAtOZjHWtsC8Vbim/CKr58cD4CALTibAh/iYMBvRm2fwYAaNYfuUApFHdBXq/x3HkJANCYVJCcD+Uv09t1ih9iQw8AgMa8vf3lYjBFyYOBvTlnzk8AgNrdDikQDy4UxzfnMliiDQCgbrOh/YUOvEkAAGxhEEuwDT4U5xmQfzhfAQBqMch21YOBvlnzYIk2AICqvR/KEmyjCMV5iTaT7gAAKoxYYcBL4B4M9l375WIRTLoDAKjKWS48CsV9fPOcvwAAe7vKq3wN1qBDcXzzroNJdwAA+0jztGZD/0sejOCNnId1DwwAANs7H+rkurJBbfP8mKNPpyfx5U/nNADAVtLOdZMx/EUPRvFurntgPjqvAQC2MhvLX/RgRG9qmnRn7WIAgM38McSd60YfinMvzNz5DQDwrC9jy01jqhSnYJwWnLZ2MQDA02ZDXpN49KE4s3YxAMDjPg59TWKhOHxbu/i98x0A4F9S28QoC4ijWJLtIUefTlM4fuHcBwD45m1uNx2dgxG/6TPnPQDAN1djDcSjDsXaKAAAvhnFVs5PGW37REEbBQDAeNsmCgfOAW0UAMCoXY09EAvFQRsFADBqo2+bKIy+faKgjQIAGKH/jnFN4odon7gzy09LAABj8FEgFor/JbdRzI0EADAC2iaE4ieDcWoyvzISAMDAzWLu+WwYhOInT5KgjQIAGK4/tE0Ixc+KJ8kq+DoBABimm6BdVCjeIhinp6cPRgIAGBhtE0Lx1s7y0xQAwBC8zQsL8ADrFD/h6NPpcXz5n5EAAHouLb92Yhgep1L8hPw09dZIAAA9Zvk1obiSYJyWaftoJACAnjrRRywUVyU9Xd0aBgCgZ97HQLw0DM/TU7wh/cUAQM9cxUA8NQybUSnekP5iAKBHUh+xiXVCcW3BWH8xANAH+oiF4trNgv5iAKC79BHvQE/xDvQXAwAdZT3iHakU7yD3F58aCQCgSxElWI9YKG4hGC/iywcjAQB0wD8T6/QR7077xJ6OPp2mqvELIwEAtOg0F+zYkUrx/k7y0xkAQBs+CMT7UymuwNGn02l8+ctIAAANu4mB+Ngw7E+luAJ52RMbewAATUrfVE8Ng1DctWCcNvYw8Q4AaCwQm1hXHe0TFTPxDgBogIl1FVMprt40mHgHANTnD4FYKO68/DWGYAwA1CHtWHdmGITivgTj1ELhhAUAqnQT7FhXGz3FNTr6dJqC8e9GAgDYU/oGemJiXX1UimtkRQoAoKJAbKWJmqkUN8CKFADAHqw00QCV4mZMw7oPCABgG28F4maoFDfk6NPpJL6kivFPRgMA2MCHGIhnhqEZKsUNiSf1KliqDQDYzEeBWCgecjBOlWInOADwFEuvCcWjCMaX8eXUSAAAjwRiK00IxaMJxov48t5IAAAlqcVyJhC3w0S7Fh19Ok3h+LWRAACBOKwrxNeGQigWjAGAsfpVIG6X9omW5Zml1jAGgPE6FYiFYtamgjEAjDYQLwyDUEz4p1r8WTAGAIGY9ugp7pCjT6eHYb3r3ZHRAIBBs1tdx6gUd0iuGJ8Eu94BgECMUDzyYJwqxVPBGAAEYoRiwVgwBgCBGKFYMBaMAUAgRihGMAYAgZiGWH2iB44+nR7Hl2U8fjIaANArVzEQTw1D96kU94CKMQD0Utp/4MQwCMUIxgAw5kA8zcutIhQjGAPA6FwJxP2jp7iH9BgDQGeZVNdTKsU9pGIMAAIxQjGCMQAIxAjFCMYAIBAjFCMYA4BAjFDMA8E4Tb67MRoAIBCzPatPDMjRp9PDsF6V4oXRAIBancZAvDAMw6FSPCB5PcRpUDEGAIEYoVgw/icYfzQaACAQsxntEwN29Ok0fWhfGwkA2Fua0D7N83gYIJXiAcvN/x+MBAAIxAjFgnEIp0YCAHZyIxCPg/aJkTj6dJrC8YWRAICtA/FnQzF8KsUjkScF/Bps8gEAm/goEI+LSvHIHH06TZt8XKYfGg0AeJBNOUZIpXhk7H4HAE96KxALxYwnGFvLGAC+l9oL0xrE54ZinLRPjJy1jAHAkmuoFI+eJdsAGLnUTngsECMUU6xM8VuwMgUA41KsMLEyFGif4Ju8MkUKyC+MBgAD90cMw2eGgYJKMd/kr46mwQQ8AIarmFAnEPMdlWIedPTpNM2+fWMkABhYIDahjgepFPOg/AR9GvQZAzAMaULdRCDmMSrFPMkOeAAMgB3qeJZKMU8q7YB3ZTQA6Jmif1gg5lkqxWzs6NPpPL68MxIA9MBtPE60SyAUU1cwPgnrZdt+MhoAdFRaRWkWA/FnQ4FQTJ3BeBLWfcbWMwaga97HMDw3DAjFNBmOF/HltZEAoANS/3Bql1gaCnZhoh07yxMXLNsGQNvSZPCJQMw+VIrZm+2hAWiRdgmEYjoXju2CB0BTtEtQKe0TVCbvgvffoJ0CgHql1SW0S1AplWIql1enWMTjpdEAoGJvYxg+NwwIxfQpHM+DzT4AqMZNWK89bDMOhGJ6GYzTJLy0pvGR0QBgR3/EY24zDoRi+h6MD9PFLJiEB8B2TKZDKGaQ4dgW0QBsylbNCMUMOhgf5mD8ymgA8IAvOQxfGgqEYsYQjlWNAbjvKgfilaFAKGZMwVjVGIAkVYfnllpDKGbs4VjVGGC8VIcRiqEUjFWNAcZFdRihGJ4Ix6rGAMOXVpY4Ux1GKIang3GqGqfKwWujATAoVpZAKIYdwvE0rKvGdsMD6D+70iEUwx7BOFWNz+LxzmgA9NJtWFeHl4YCoRj2D8fHYd1S8dJoAPRCapU4j2F4bigQiqH6cDzL4dhEPIDusswaQjE0EIxTS8U8Hm+MBkCnpFaJMxPpEIqh2XCspQKgO96HdbuEiXQIxdBSOJ4FLRUAbbHmMEIxdCgYW6UCoFlWlUAohg6H40lYV41tFw1QD9szIxRDj8LxNIfjF0YDoDI24EAohp6G41lYr1RhVzyA3ekbRiiGAQTjot84HSbjAWwurTc81zeMUAzDC8eppeK10QB40m0OwwtDgVAMww3Hk7BuqRCOAb6XJtGdCcMIxTCucGzzD4C7MJyuhzbfQCiGEYfjaVhXjoVjQBgGoRiEY+EYEIZBKAaEY0AYBqEYEI4BYRiEYkA4BoRhEIoB4RgQhkEoBp4Px5NgnWOgm27z9elSGAahGJoOxyfB9tFAB8KwTTdAKIY2w3HaPvosH8Ix0KSrHIaXhgKEYuhSQJ6FdfX4yGgANfqQw/DKUIBQDF0Ox9Owrhy/MhpARUyeA6EYehuOJzkcz4LWCmA3NzkILwwFCMXQ93Cc+o5PckB+YUSADXzIYfjaUIBQDEMMyNOwrhxb0g24L60ikVokFlokQCiGsYTjwxyOU/XYxDwYtw85CC8NBQjFMOaAPA2qxzA2qsIgFAOPhGO9xzBsaQWJy6BXGIRiYOOAPMnhOIVk7RXQb2mTjUWw/TIIxcBeAfkkh2NbSkN/FO0RlzbZAKEYqDYcH5bCsY1BoJtBOLVHLLRHgFAMCMgwJvqEQSgGOhKQJzkcz4IJetBkEE6tEZeGA4RioHsBWQUZ6lG0RiwFYRCKgf4G5GkwSQ92DcJ6hEEoBgYUkssB2TJv8LCrcNcasTIcIBQDww7Ixzkcz4I+ZMat6A9eBusIg1AMjDogH+aArIrMWFyVQrC2CEAoBh4MyUUVuTj0ItN33ybJhfVEOdVgQCgGtg7J5YD80ojQkxC8LIXglSEBhGKgzpCcqsoqyQjBgFAMjD4kH98LyXqSqVvqCb4O2iEAoRjocEie5HBchGUtF+zjthSAr2MAXhoSQCgG+hqUj0tB+VhQ5pkAXA7BqsCAUAwMPihPwl1FWX/yuNzk8LsSgAGhGOD7oHwY7qrJk9KPheV+h99VuKsAr6wNDAjFAPuF5UkpLKef04bRDV/CXdU3Hct4fBZ+AaEYoL3AXD6shFGdq/y6LL0KvoBQDNCz0JxM82sRmpOxV5vT5LbVvcC7yofQCwjFACMM0NPSPxYtGiG/Ht/717vW65x6d8sT1dKPy4H2uvTrKxtcAAjFAHUG63KYroNVGgCEYgAAqNaBIQAAQCgGAAChGAAAhGIAABi1/xdgAMeO/4ldgZOoAAAAAElFTkSuQmCC'; + +/** Company branding logo image size to display on the card picker */ +export const BRAND_LOGO_IMAGE_SIZE = '30%'; + +export const listStyle = css` + .list { + --mdc-theme-primary: var(--accent-color); + --mdc-list-vertical-padding: 0px; + overflow: hidden; + } +`; diff --git a/src/decorators/storage.ts b/src/decorators/storage.ts new file mode 100644 index 0000000..26d632c --- /dev/null +++ b/src/decorators/storage.ts @@ -0,0 +1,306 @@ +/* + Based on the following Github repository: + https://github.com/gugamm/localstorage-decorator/blob/master/local-storage.ts +*/ + +/** + * Local Storage service. + */ +export class LStorageService { + + /** + * Dictionary that will hold property values. + */ + private _propValues: any = {}; + + + /** + * Adds a value from long-term window.localStorage area to the short-term + * property value storage area. + * + * @storageKey Key used to access the property value. + */ + public addPropertyValue(storageKey: any): void { + if (!this._propValues.hasOwnProperty(storageKey)) { + const storageData = window.localStorage.getItem(storageKey); + this._propValues[storageKey] = (storageData) ? JSON.parse(storageData) : null; + } + } + + + /** + * Gets the value from the short-term property value storage area. + * Note that the value is not retrieved from long-term window.localStorage. + * + * @storageKey Key used to access the property value. + */ + public getPropertyValue(storageKey: string): any { + return this._propValues[storageKey]; + } + + + /** + * Sets the value in short-term property value storage area. + * Note that the value is not saved to long-term window.localStorage area. + * + * @storageKey Key used to access the property value. + * @value Property value. + */ + public setPropertyValue(storageKey: string, value: any): any { + this._propValues[storageKey] = value; + } + + + /** + * Saves all values stored in the short-term property value storage area + * to the long-term window.localStorage area. + */ + public saveProperties(): void { + for (const prop in this._propValues) + if (this._propValues.hasOwnProperty(prop)) + window.localStorage.setItem(prop, JSON.stringify(this._propValues[prop])); + } + + + /** + * Saves short-term property value storage to the long-term window.localStorage + * area. + * + * @storageKey Key used to access the property value. + */ + public saveProperty(storageKey: string): void { + if (this._propValues.hasOwnProperty(storageKey)) + window.localStorage.setItem(storageKey, JSON.stringify(this._propValues[storageKey])); + } + + + /** + * Gets a value from long-term window.localStorage area. + * + * @storageKey Key used to access the storage value. + * @defaultValue Default value to return if no value was found for specified storageKey. + */ + public getStorageValue(storageKey: any, defaultValue: any = null): any { + const storageData = window.localStorage.getItem(storageKey); + return (storageData) ? JSON.parse(storageData) : defaultValue; + } + + + /** + * Sets the value in long-term storage area. + * Note that the value is not saved to short-term property values area. + * + * @storageKey Key used to access the storage value. + * @value Property value. + */ + public setStorageValue(storageKey: string, value: any): any { + window.localStorage.setItem(storageKey, JSON.stringify(value)); + } + + + /** + * Removes the value in long-term storage area. + * Note that the value is not removed from the property values area. + * + * @storageKey Key used to access the storage value. + */ + public clearStorageValue(storageKey: string): any { + window.localStorage.removeItem(storageKey); + } + + + /** + * Removes ALL values from the long-term window.localStorage area, and set the short-term + * property value storage area value to null for each property. + * + * BE CAREFUL WHEN USING THIS METHOD, as it removes ALL values from the long-term + * window.localStorage area that other applications may be using! + */ + public clearStorage(): void { + window.localStorage.clear(); + for (const prop in this._propValues) + if (this._propValues.hasOwnProperty(prop)) + this._propValues[prop] = null; + } + + + /** + * Removes a value from the long-term window.localStorage area, and set the short-term + * property value storage area value to null. + * + * @storageKey Key used to access the property value. + */ + public clearStorageByKey(storageKey: string): void { + if (this._propValues.hasOwnProperty(storageKey)) { + window.localStorage.removeItem(storageKey); + this._propValues[storageKey] = null; + } + } +} + +export const storageService = new LStorageService(); + +/** + * Local Storage property decorator. + * + * Args: + * @key Key that will represent its value in local storage. + * If two classes have two properties with same key, they will have the same value. + * @autoSave If true, the property value will be saved immediately when the value is changed; + * If false, you must issue a separate call to `storageService.` to store the value(s). + * + * If autosave is on, every change in a property will trigger a JSON.stringfy call. If this is a + * performance issue for you, turn autosave off, and save data wherever you want with storageService. + */ +export function storage( + options: { + key?: string; + autoSave?: boolean; + } + ) { + + return function (target: Object, propName: string) { + + // validations. + if (!options.autoSave) + options.autoSave = true; + + // set property key id; defaults to property name if not supplied. + const propNameId: string = (options.key) ? options.key : propName; + + storageService.addPropertyValue(propNameId); + + function getValue(): any { + return storageService.getPropertyValue(propNameId); + } + + function setValueAuto(val: any) { + storageService.setPropertyValue(propNameId, val); + storageService.saveProperty(propNameId); + } + + function setValue(val: any) { + storageService.setPropertyValue(propNameId, val); + } + + Object.defineProperty(target, propName, { + configurable: true, + enumerable: true, + get: getValue, + set: (options.autoSave) ? setValueAuto : setValue + }); + } +} + + +/* + +* ----------------------------------------------------------------------------------------------------------------- +* storage decorator can be used with property. +* It will initialize the property value with null if no localStorage has been found. +* You can provide a default value (see example below). +* +* Args: +* autoSave : if true storage will save property value at any attribution. +* If autosave is on, every change in a property will trigger a JSON.stringfy call. +* If this is a performance issue for you, turn autosave off, and save data wherever you want with storageService. +* key : key that will represent its value in local storage. +* If two classes have two properties with same key, they will have the same value. +* ----------------------------------------------------------------------------------------------------------------- +@storage(autoSave : boolean, key ?: string) + + +* ----------------------------------------------------------------------------------------------------------------- +* storageService methods can also be called / used directly. +* ----------------------------------------------------------------------------------------------------------------- +// create a storage for a key and initialize with localStorage value. +// if no value is found, then initialize with null. +storageService.addPropertyValue(key : string) : void + +// return the storage value. +storageService.getPropertyValue(key : string) : void + +// set the storage value. +storageService.setPropertyValue(key : string, value : any) : void + +// save all storages. +storageService.saveProperties() : void + +// save a storage by key. +storageService.saveProperty(key : string) : void + +// clear browser localStorage and set all storage values to null. +storageService.clearStorage() : void + +// clear localStorage for that key and set its value to null. +storageService.clearStorageByKey(key : string) : void + + +* ----------------------------------------------------------------------------------------------------------------- +* Development examples. +* ----------------------------------------------------------------------------------------------------------------- +import {storage, LStorageService, storageService} from 'localstorage-decorator'; + +**** use in class with decorators. + +//auto-save +class Student { + @storage("STUDENT_NAME", true) + public name : string; +} + +//no auto-save +class Student { + @storage("STUDENT_NAME", false) + public name : string; +} + +//using default value +class Student { + @storage("STUDENT_NAME", false) + public name : string = this.name || "my default value"; +} + +//default key +class Student { + @storage() //key will be equal to property name. In this case, "name" + public name : string; +} + +**** use in class without decorators. + +class Student { + public name : string; + + constructor() { + storageService.addPropertyValue("STUDENT_NAME"); + this.name = storageService.getPropertyValue("STUDENT_NAME"); + } +} + +**** saving values. + +// WITH DECORATOR +// if autoSave is on, it will automatically save. Otherwise, use storageService +var student : Student = new Student(); +student.name = "Banana"; + +// WITHOUT DECORATOR +student.name = "Banana"; +storageService.setPropertyValue("STUDENT_NAME", student.name); +storageService.saveProperty("STUDENT_NAME"); + +// or +storageService.saveProperties(); + +**** clearing storage. + +// clear storage for specified key. +storageService.clearStorageByKey("STUDENT_NAME"); + +// clear all storage. +storageService.clearStorage(); + +(storageService.getPropertyValue("STUDENT_NAME") === null) // this is true now + +*/ diff --git a/src/editor/album-fav-browser-editor.ts b/src/editor/album-fav-browser-editor.ts new file mode 100644 index 0000000..1b10879 --- /dev/null +++ b/src/editor/album-fav-browser-editor.ts @@ -0,0 +1,128 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { Section } from '../types/section'; + + +const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'albumFavBrowserTitle', + label: 'Section title text', + help: 'displayed at the top of the section', + required: false, + type: 'string', + }, + { + name: 'albumFavBrowserSubTitle', + label: 'Section sub-title text', + help: 'displayed below the section title', + required: false, + type: 'string', + }, + { + name: 'albumFavBrowserItemsPerRow', + label: '# of items to display per row', + help: 'use 1 for list format', + required: true, + type: 'integer', + default: 3, + valueMin: 1, + valueMax: 12, + }, + { + name: 'albumFavBrowserItemsHideTitle', + label: 'Hide item row title text', + required: false, + selector: { boolean: {} }, + }, + { + name: 'albumFavBrowserItemsHideSubTitle', + label: 'Hide item row sub-title text', + help: 'if Title visible', + required: false, + selector: { boolean: {} }, + }, + { + name: 'albumFavBrowserItemsSortTitle', + label: 'Sort items by Title', + required: false, + selector: { boolean: {} }, + }, +]; + + +class AlbumFavBrowserEditor 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` +
+ Settings that control the Album Favorites 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-album-fav-browser-editor', AlbumFavBrowserEditor); diff --git a/src/editor/artist-fav-browser-editor.ts b/src/editor/artist-fav-browser-editor.ts new file mode 100644 index 0000000..32b8ca1 --- /dev/null +++ b/src/editor/artist-fav-browser-editor.ts @@ -0,0 +1,127 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { Section } from '../types/section'; + + +const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'artistFavBrowserTitle', + label: 'Section title text', + help: 'displayed at the top of the section', + required: false, + type: 'string', + }, + { + name: 'artistFavBrowserSubTitle', + label: 'Section sub-title text', + help: 'displayed below the section title', + required: false, + type: 'string', + }, + { + name: 'artistFavBrowserItemsPerRow', + label: '# of items to display per row', + help: 'use 1 for list format', + required: true, + type: 'integer', + default: 3, + valueMin: 1, + valueMax: 12, + }, + { + name: 'artistFavBrowserItemsHideTitle', + label: 'Hide item row title text', + required: false, + selector: { boolean: {} }, + }, + { + name: 'artistFavBrowserItemsHideSubTitle', + label: 'Hide item row sub-title text', + help: 'if Title visible', + required: false, + selector: { boolean: {} }, + }, + { + name: 'artistFavBrowserItemsSortTitle', + label: 'Sort items by Title', + required: false, + selector: { boolean: {} }, + }, +]; + + +class ArtistFavBrowserEditor 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` +
+ Settings that control the Artist Favorites 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-artist-fav-browser-editor', ArtistFavBrowserEditor); diff --git a/src/editor/audiobook-fav-browser-editor.ts b/src/editor/audiobook-fav-browser-editor.ts new file mode 100644 index 0000000..06d3828 --- /dev/null +++ b/src/editor/audiobook-fav-browser-editor.ts @@ -0,0 +1,127 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { Section } from '../types/section'; + + +const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'audiobookFavBrowserTitle', + label: 'Section title text', + help: 'displayed at the top of the section', + required: false, + type: 'string', + }, + { + name: 'audiobookFavBrowserSubTitle', + label: 'Section sub-title text', + help: 'displayed below the section title', + required: false, + type: 'string', + }, + { + name: 'audiobookFavBrowserItemsPerRow', + label: '# of items to display per row', + help: 'use 1 for list format', + required: true, + type: 'integer', + default: 3, + valueMin: 1, + valueMax: 12, + }, + { + name: 'audiobookFavBrowserItemsHideTitle', + label: 'Hide item row title text', + required: false, + selector: { boolean: {} }, + }, + { + name: 'audiobookFavBrowserItemsHideSubTitle', + label: 'Hide item row sub-title text', + help: 'if Title visible', + required: false, + selector: { boolean: {} }, + }, + { + name: 'audiobookFavBrowserItemsSortTitle', + label: 'Sort items by Title', + required: false, + selector: { boolean: {} }, + }, +]; + + +class AudiobookFavBrowserEditor 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` +
+ Settings that control the Audiobook Favorites 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-audiobook-fav-browser-editor', AudiobookFavBrowserEditor); diff --git a/src/editor/base-editor.ts b/src/editor/base-editor.ts new file mode 100644 index 0000000..e856a49 --- /dev/null +++ b/src/editor/base-editor.ts @@ -0,0 +1,268 @@ +// lovelace card imports. +import { css, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { fireEvent, HomeAssistant } from 'custom-card-helpers'; + +// our imports. +import { CardConfig } from '../types/card-config'; +import { Store } from '../model/store'; +import { ConfigArea } from '../types/config-area'; +import { Section } from '../types/section'; +import { MediaPlayer } from '../model/media-player'; +import { SpotifyPlusService } from '../services/spotifyplus-service'; +import { dispatch, getObjectDifferences, getSectionForConfigArea } from '../utils/utils'; +import { CONFIG_UPDATED } from '../constants'; +import { EditorConfigAreaSelectedEvent } from '../events/editor-config-area-selected'; + + +export abstract class BaseEditor extends LitElement { + + @property({ attribute: false }) hass!: HomeAssistant; + @property({ attribute: false }) config!: CardConfig; + @property({ attribute: false }) store!: Store; + @property({ attribute: true }) section!: Section; + @property({ attribute: false }) footerBackgroundColor?: string; + + /** MediaPlayer instance created from the configuration entity id. */ + public player!: MediaPlayer; + + /** SpotifyPlus services instance. */ + public spotifyPlusService!: SpotifyPlusService; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(); + + } + + + /** + * Style definitions used by this TemplateResult. + */ + static get styles() { + return css` + ha-svg-icon { + margin: 5px; + } + ha-control-button { + white-space: nowrap; + } + ha-control-button-group { + margin: 5px; + } + div { + margin-top: 20px; + } + `; + } + + + /** + * Home Assistant will call setConfig(config) when the configuration changes. This + * is most likely to occur when changing the configuration via the UI editor, but + * can also occur if YAML changes are made (for cards without UI config editor). + * + * If you throw an exception in this method (e.g. invalid configuration, etc), then + * Home Assistant will render an error card to notify the user. Note that by doing + * so will also disable the Card Editor UI, and the card must be configured manually! + * + * The config argument object contains the configuration specified by the user for + * the card. It will minimally contain: + * `config.type = "custom:my-custom-card"` + * + * The `setConfig` method MUST be defined, and is in fact the only function that must be. + * It doesn't need to actually DO anything, though. + * + * Note that setConfig will ALWAYS be called at the start of the lifetime of the card + * BEFORE the `hass` object is first provided. It MAY be called several times during + * the lifetime of the card, e.g. if the configuration of the card is changed. + * + * We use it here to update the internal config property, as well as perform some + * basic validation and initialization of the config. + * + * @param config Contains the configuration specified by the user for the card. + */ + public setConfig(config: CardConfig): void { + + //console.log("setConfig (base-editor) enter\n- this.section=%s\n- Store.selectedConfigArea=%s", + // JSON.stringify(this.section), + // JSON.stringify(Store.selectedConfigArea), + //); + + // copy the passed configuration object to create a new instance. + const newConfig: CardConfig = JSON.parse(JSON.stringify(config)); + + // if no sections are configured then configure the default. + if (!newConfig.sections || newConfig.sections.length === 0) { + newConfig.sections = [Section.PLAYER]; + Store.selectedConfigArea = ConfigArea.GENERAL; + } + + // store configuration so other card sections can access them. + this.config = newConfig; + + //console.log("setConfig (base-editor) - configuration:\n%s", + // JSON.stringify(this.config, null, 2), // prettyprint + //); + + //console.log("setConfig (base-editor) exit\n- this.section=%s\n- Store.selectedConfigArea=%s", + // JSON.stringify(this.section), + // JSON.stringify(Store.selectedConfigArea), + //); + + } + + + /** + * Called by the various editor forms when a value has been changed in the configuration editor(s). + * + * @param changedConfig A CardConfig object that contains changes made in the editor. + */ + protected configChanged(changedConfig: CardConfig | undefined = undefined) { + + //console.log("configChanged (base-editor) - configuration settings changed\n- this.section=%s\n- Store.selectedConfigArea=%s", + // JSON.stringify(this.section), + // JSON.stringify(Store.selectedConfigArea), + //); + + //console.log("configChanged (base-editor) - configuration settings changed\n- this.section=%s\n- Store.selectedConfigArea=%s\n- Values changed:\n%s", + // JSON.stringify(this.section), + // JSON.stringify(Store.selectedConfigArea), + // JSON.stringify(getObjectDifferences(this.config, changedConfig), null, 2), + //); + + // were configuration changes supplied? + let changedValues = {} + if (changedConfig) { + + // get configuration changes. + changedValues = getObjectDifferences(this.config, changedConfig); + + // update the existing configuration with supplied changes. + this.config = { + ...this.config, + ...changedConfig, + }; + } + + // get section to display based upon selected configarea, and ensure that the + // section area is displayed. + const configAreaSection = getSectionForConfigArea(Store.selectedConfigArea); + if (this.section != configAreaSection) { + + //console.log("configChanged (base-editor) - forcing selected section to match selected ConfigArea\n- this.section=%s\n- configAreaSection=%s\n- config.sections=%s", + // JSON.stringify(this.section), + // JSON.stringify(configAreaSection), + // JSON.stringify(this.config.sections), + //); + + // show the config area and set the section references. + this.section = configAreaSection; + this.store.section = this.section; + + // inform the card that it needs to show the section for the selected ConfigArea + // by dispatching the EDITOR_CONFIG_AREA_SELECTED event. + document.dispatchEvent(EditorConfigAreaSelectedEvent(this.section)); + } + + //console.log("configChanged (base-editor) - configuration settings changed\n- changedConfig:\n%s", + // JSON.stringify(changedConfig,null,2) + //); + + // inform Home assistant dashboard that our configuration has changed. + fireEvent(this, 'config-changed', { config: this.config }); + + // request an update, which will force the card editor to re-render. + super.requestUpdate(); + + // inform configured component of the changes; we will let them decide whether to + // re-render the component, refresh media lists, etc. + dispatch(CONFIG_UPDATED, changedValues); + + //const configAreaSection2 = getSectionForConfigArea(Store.selectedConfigArea); + //console.log("configChanged (base-editor) - after requestUpdate\n- this.section=%s\n- Store.selectedConfigArea=%s", + // JSON.stringify(this.section), + // JSON.stringify(configAreaSection2), + // JSON.stringify(Store.selectedConfigArea), + //); + + } + + + protected dispatchClose() { + //console.log("dispatchClose (base-editor) - method called - close form?"); + return super.dispatchEvent(new CustomEvent('closed')); + } + + + /** + * Creates the common services and data areas that are used by the various card editors. + * + * Note that this method cannot be called from `setConfig` method, as the `hass` property + * has not been set set! + */ + public createStore(): void { + + // have we already created the store? if so, then don't do it again. + // we check this here, as most of the `x-editor` inherit from BaseEditor and call this method. + if (this.store) { + return; + } + + // get section to display based upon selected configarea. + const configAreaSection = getSectionForConfigArea(Store.selectedConfigArea); + + // if no sections are configured then configure the default. + if (!this.config.sections || this.config.sections.length === 0) { + this.config.sections = [Section.PLAYER]; + Store.selectedConfigArea = ConfigArea.GENERAL; + } + + // create the store. + this.store = new Store(this.hass, this.config, this, configAreaSection, this.config.entity); + + // set other references obtained from the store. + this.player = this.store.player; + this.section = this.store.section; + + //console.log("createStore (base-editor) - store created\n- this.section=%s\n- Store.selectedConfigArea=%s", + // JSON.stringify(this.section), + // JSON.stringify(Store.selectedConfigArea), + //); + + } + + + /** + * Sets the section value and requests an update to show the section. + * + * @param section Section to show. + */ + public SetSection(section: Section): void { + + // is the session configured for display? + if (!this.config.sections || this.config.sections.indexOf(section) > -1) { + + //console.log("SetSection (base-editor) - set section reference and display the section\n- OLD section=%s\n- NEW section=%s", + // JSON.stringify(this.section), + // JSON.stringify(section) + //); + + this.section = section; + this.store.section = this.section; + super.requestUpdate(); + + } else { + + //console.log("SetSection (base-editor) - section is not active: %s", + // JSON.stringify(section) + //); + + } + } +} diff --git a/src/editor/device-browser-editor.ts b/src/editor/device-browser-editor.ts new file mode 100644 index 0000000..a4ec8fd --- /dev/null +++ b/src/editor/device-browser-editor.ts @@ -0,0 +1,106 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { Section } from '../types/section'; + + +const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'deviceBrowserTitle', + label: 'Section title text', + help: 'displayed at the top of the section', + required: false, + type: 'string', + }, + { + name: 'deviceBrowserSubTitle', + label: 'Section sub-title text', + help: 'displayed below the section title', + required: false, + type: 'string', + }, + { + name: 'deviceBrowserItemsPerRow', + label: '# of items to display per row', + help: 'use 1 for list format', + required: true, + type: 'integer', + default: 3, + valueMin: 1, + valueMax: 12, + }, + { + name: 'deviceBrowserItemsHideTitle', + label: 'Hide item row title text', + required: false, + selector: { boolean: {} }, + }, + { + name: 'deviceBrowserItemsHideSubTitle', + label: 'Hide item row sub-title text', + help: 'if Title visible', + required: false, + selector: { boolean: {} }, + }, +]; + + +class SourceSettingsEditor 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` +
+ Settings that control the Sources 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 { + } + + `; + } + +} + +customElements.define('spc-device-browser-editor', SourceSettingsEditor); diff --git a/src/editor/editor-form.ts b/src/editor/editor-form.ts new file mode 100644 index 0000000..d3df2c5 --- /dev/null +++ b/src/editor/editor-form.ts @@ -0,0 +1,226 @@ +// lovelace card imports. +import { css, html, LitElement, PropertyValues, TemplateResult } from 'lit'; +import { property, query } from 'lit/decorators.js'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { CardConfig } from '../types/card-config'; + + +class Form extends BaseEditor { + + @property({ attribute: false }) schema!: unknown; + @property({ attribute: false }) data!: unknown; + @property() changed!: (ev: CustomEvent) => void; + @property() isRenderRootStylesUpdated!: boolean; + + /** query selector for the currently selected element. */ + @query("#elmHaForm") private _elmHaForm!: LitElement; + + + /** + * 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 { + + // style renderRoot elements. + this._styleRenderRootElements(); + + // render the control. + return html` + + `; + } + + + /** + * Style definitions used by this TemplateResult. + */ + static get styles() { + return css` + + // #elmHaForm::part(root) { + // border: 1px solid gold !important; + // } + + `; + } + + + /** + * 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); + + // if renderRoot elements were not styled, then request another update. + if (!this.isRenderRootStylesUpdated) { + this.requestUpdate(); + } + + } + + + /** + * Handles a "value-changed" event. + * This event is raised whenever a form value is changed in the UI. + */ + protected OnValueChanged(args: CustomEvent): void { + + //console.log("OnValueChanged (editor-form) - event:\n%s", + // JSON.stringify(args,null,2) + //); + + // get the updated changes from event details. + const changedConfig = (args.detail.value as CardConfig); + + // call configuration changed method to update the existing config with our changes, + // fire an event that something has changed, and re-render the card preview. + this.configChanged(changedConfig); + + } + + + /** + * Styles all renderRoot elements under the element to make them + * more compact and remove wasted space. + */ + private _styleRenderRootElements() { + + // have we already updated the renderRoot styles? + if (this.isRenderRootStylesUpdated) { + return; + } + + // element may not be present when the configuration editor is initially displayed. + // we have to wait for a section to be displayed first. + if (!this._elmHaForm) { + this.requestUpdate(); + return; + } + + // if shadowRoot has not updated yet then we can't do anything. + if (!this._elmHaForm.shadowRoot) { + this.requestUpdate(); + return; + } + + // if has not completely updated yet then we can't do anything. + if (!this._elmHaForm?.updateComplete) { + this.requestUpdate(); + return; + } + + // if has not completely updated yet then we can't do anything. + // has renderRoot happened yet? if not, then don't bother! + if (!this.hasUpdated) { + this.requestUpdate(); + return; + } + + // get all controls defined to the root
element. + const root = this._elmHaForm.renderRoot.querySelector(".root"); + if (!root) { + //console.log("_styleRenderRootElements (editor-form) - this._elmHaForm.renderRoot.querySelector('.root') is undefined"); + } else { + + // process all child elements of the root. + for (let idx = 0; idx < root.children.length; idx++) { + + const child = root.children[idx]; + + // only need to process specified tagNames. + // other tagnames may need to be added, as we only use a small set of the allowed controls. + if (child.tagName == "HA-FORM-STRING") { + child.setAttribute("style", "margin-bottom: var(--ha-form-style-string-margin-bottom, 24px);"); + + } else if (child.tagName == "HA-SELECTOR") { + child.setAttribute("style", "margin-bottom: var(--ha-form-style-selector-margin-bottom, 24px);"); + + // HA-SELECTOR elements can have different underlying types (HA-SELECTOR-BOOLEAN, etc). + // we will style the underlying type based on its tagName. + const grandChild = root.children[idx].shadowRoot?.firstElementChild; + if (grandChild) { + + //console.log("HA-SELECTOR child shadowRoot firstElementChild tagName = %s", JSON.stringify(grandChild.tagName)); + if (grandChild.tagName == "HA-SELECTOR-BOOLEAN") { + const haFormField = grandChild.shadowRoot?.firstElementChild; + //console.log("HA-SELECTOR-BOOLEAN first element = %s", JSON.stringify(haFormField?.tagName)); + 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); + } + } + + } else { + //console.log("_styleRenderRootElements (editor-form) - HA-SELECTOR firstElementChild has no shadowRoot!"); + } + + } else if (child.tagName == "HA-FORM-MULTI_SELECT") { + child.setAttribute("style", "margin-bottom: var(--ha-form-style-multiselect-margin-bottom, 24px);"); + + } else if (child.tagName == "HA-FORM-INTEGER") { + 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); + } + } + + // set a timeout to re-apply styles in a few milliseconds, as some of the shadowRoot + // elements may not have completed updating when the first render was ran. + // we will also indicate that styles have been updated, so we don't do it again. + setTimeout(() => { + this._styleRenderRootElements(); + this.isRenderRootStylesUpdated = true; + }, 50); + + // request an update to render the changes. + this._elmHaForm.requestUpdate(); + } + } + +} + + +/** + * Formats labels for each editable field in the supplied schema. + * + * It uses the value assigned to the "label" key as the title by default. + * If a label key is not supplied, then it uses the value assigned to the "name" key, + * and converts it to proper-case (e.g. "myFieldName" returns "My Field Name"). + * + * If a "help" key value is supplied, then it adds the value to the label surrounded + * by parenthesis (e.g. "My Field Name (this is the help value)". + */ +export function formatLabel({ help, label, name }: { name: string; help: string; label: string }) { + + // if label was supplied then just use it as-is. + if (label) { + return label + (help ? ` (${help})` : ''); + } + + // otherwise use the proper-case value of the name. + let unCamelCased = name.replace(/([A-Z])/g, ' $1'); + unCamelCased = unCamelCased.charAt(0).toUpperCase() + unCamelCased.slice(1); + + // if help key supplied, then add the help value in parenthesis. + return unCamelCased + (help ? ` (${help})` : ''); +} + + +customElements.define('spc-editor-form', Form); diff --git a/src/editor/editor.ts b/src/editor/editor.ts new file mode 100644 index 0000000..fa51008 --- /dev/null +++ b/src/editor/editor.ts @@ -0,0 +1,340 @@ +// lovelace card imports. +import { css, html, nothing, PropertyValues, TemplateResult } from 'lit'; +import { state } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; + +// our imports. +import './editor-form'; +import './general-editor'; +import './player-editor'; +import './album-fav-browser-editor'; +import './artist-fav-browser-editor'; +import './audiobook-fav-browser-editor'; +import './device-browser-editor'; +import './episode-fav-browser-editor'; +import './playlist-fav-browser-editor'; +import './recent-browser-editor'; +import './search-media-browser-editor'; +import './show-fav-browser-editor'; +import './track-fav-browser-editor'; +import './userpreset-browser-editor'; +import { BaseEditor } from './base-editor'; +import { ConfigArea } from '../types/config-area'; +import { Section } from '../types/section'; +import { Store } from '../model/store'; +import { SHOW_SECTION } from '../constants'; +import { EditorConfigAreaSelectedEvent } from '../events/editor-config-area-selected'; +import { + getConfigAreaForSection, + getSectionForConfigArea, +} from '../utils/utils'; + + +class CardEditor extends BaseEditor { + + @state() private configArea = ConfigArea.GENERAL; + + /** + * 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 { + + // just in case hass property has not been set yet. + if (!this.hass) { + return html``; + } + + // just in case config property has not been set yet. + if (!this.config) { + return html``; + } + + // ensure store is created. + super.createStore(); + + //console.log("render (editor) - rendering editor\n- this.store.section=%s\n- this.section=%s\n- Store.selectedConfigArea=%s", + // JSON.stringify(this.store.section), + // JSON.stringify(this.section), + // JSON.stringify(Store.selectedConfigArea), + //); + + return html` + + ${[ConfigArea.GENERAL, ConfigArea.PLAYER, ConfigArea.DEVICE_BROWSER, ConfigArea.USERPRESET_BROWSER, ConfigArea.RECENT_BROWSER].map( + (configArea) => html` + this.OnConfigSectionClick(configArea)} + > + ${configArea} + + `, + )} + + + ${[ConfigArea.PLAYLIST_FAVORITES, ConfigArea.ALBUM_FAVORITES, ConfigArea.ARTIST_FAVORITES, ConfigArea.TRACK_FAVORITES, ConfigArea.AUDIOBOOK_FAVORITES].map( + (configArea) => html` + this.OnConfigSectionClick(configArea)} + > + ${configArea} + + `, + )} + + + ${[ConfigArea.EPISODE_FAVORITES, ConfigArea.SHOW_FAVORITES, ConfigArea.SEARCH_MEDIA_BROWSER].map( + (configArea) => html` + this.OnConfigSectionClick(configArea)} + > + ${configArea} + + `, + )} + + +
+ ${this.subEditor()} +
+ `; + } + + + /** + * Style definitions used by this TemplateResult. + * + * Use the following styles to control the HA-FORM look and feel; the values + * listed in the style below give the dynamically generated content a more + * compact look and feel, which is nice when a LOT of editor settings are defined. + * They are applied to the shadowDOM via the _styleRenderRootElements function in editor.form.ts. + * + * --ha-form-style-integer-margin-bottom: 0.5rem; + * --ha-form-style-multiselect-margin-bottom: 0.5rem; + * --ha-form-style-selector-margin-bottom: 0.5rem; + * --ha-form-style-selector-boolean-min-height: 28px; + * --ha-form-style-string-margin-bottom: 0.5rem; + */ + static get styles() { + return css` + + .spc-card-editor { + /* control the look and feel of the HA-FORM element. */ + --ha-form-style-integer-margin-bottom: 0.5rem; + --ha-form-style-multiselect-margin-bottom: 0.5rem; + --ha-form-style-selector-margin-bottom: 0.5rem; + --ha-form-style-selector-boolean-min-height: 28px; + --ha-form-style-string-margin-bottom: 0.5rem; + } + + ha-control-button-group { + margin-bottom: 8px; + } + + ha-control-button[selected] { + --control-button-background-color: var(--primary-color); + } + `; + } + + + private subEditor() { + + // show the desired section editor. + return choose(this.configArea, [ + [ + ConfigArea.ALBUM_FAVORITES, + () => html``, + ], + [ + ConfigArea.ARTIST_FAVORITES, + () => html``, + ], + [ + ConfigArea.AUDIOBOOK_FAVORITES, + () => html``, + ], + [ + ConfigArea.DEVICE_BROWSER, + () => html``, + ], + [ + ConfigArea.EPISODE_FAVORITES, + () => html``, + ], + [ + ConfigArea.GENERAL, + () => html``, + ], + [ + ConfigArea.PLAYER, + () => html``, + ], + [ + ConfigArea.PLAYLIST_FAVORITES, + () => html``, + ], + [ + ConfigArea.RECENT_BROWSER, + () => html``, + ], + [ + ConfigArea.SEARCH_MEDIA_BROWSER, + () => html``, + ], + [ + ConfigArea.SHOW_FAVORITES, + () => html``, + ], + [ + ConfigArea.TRACK_FAVORITES, + () => html``, + ], + [ + ConfigArea.USERPRESET_BROWSER, + () => html``, + ], + ]); + } + + + /** + * Handles the `click` event fired when an editor section button is clicked. + * + * This will set the configArea attribute, which will display the selected editor section settings. + * + * @param args Event arguments that contain the configArea that was clicked on. + */ + private OnConfigSectionClick(configArea: ConfigArea) { + + // show the section that we are editing. + const sectionNew = getSectionForConfigArea(configArea); + + //console.log("OnConfigSectionClick (editor)\n- OLD configArea=%s\n- NEW configArea=%s\n- OLD section=%s\n- NEW section=%s\n- Store.selectedConfigArea=%s", + // JSON.stringify(this.configArea), + // JSON.stringify(configArea), + // JSON.stringify(this.section), + // JSON.stringify(sectionNew), + // JSON.stringify(Store.selectedConfigArea), + //); + + // store selected ConfigArea. + Store.selectedConfigArea = configArea; + + // show the config area and set the section references. + this.configArea = configArea; + this.section = sectionNew; + this.store.section = sectionNew; + + // inform the card that it needs to show the section for the selected ConfigArea + // by dispatching the EDITOR_CONFIG_AREA_SELECTED event. + document.dispatchEvent(EditorConfigAreaSelectedEvent(this.section)); + } + + + /** + * Invoked when the component is added to the document's DOM. + * + * In `connectedCallback()` you should setup tasks that should only occur when + * the element is connected to the document. The most common of these is + * adding event listeners to nodes external to the element, like a keydown + * event handler added to the window. + * + * Typically, anything done in `connectedCallback()` should be undone when the + * element is disconnected, in `disconnectedCallback()`. + */ + connectedCallback() { + + // invoke base class method. + super.connectedCallback(); + + // add window level event listeners. + window.addEventListener(SHOW_SECTION, this.OnFooterShowSection); + } + + + /** + * Invoked when the component is removed from the document's DOM. + * + * This callback is the main signal to the element that it may no longer be + * used. `disconnectedCallback()` should ensure that nothing is holding a + * reference to the element (such as event listeners added to nodes external + * to the element), so that it is free to be garbage collected. + * + * An element may be re-connected after being disconnected. + */ + disconnectedCallback() { + + // remove window level event listeners. + window.removeEventListener(SHOW_SECTION, this.OnFooterShowSection); + + // invoke base class method. + super.disconnectedCallback(); + } + + + /** + * 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); + + //console.log("firstUpdated (editor) - 1st render complete - changedProperties keys:\n- %s", + // JSON.stringify(Array.from(changedProperties.keys())), + //); + + // if there are things that you only want to happen one time when the configuration + // is initially loaded, then do them here. + + // at this point, the first render has occurred. + // select the configarea for the first section that has been configured so that its settings + // are automatically displayed when the card editor dialog opens. + // if the media player entity has not been configured then display the GENERAL configArea. + // make sure we check if `this.config` has been set before attempting to access anything + // in the configuration settings; otherwise, an uncaught exception is raised! + let configArea = getConfigAreaForSection(this.section); + if (this.config) { + if (!this.config.entity) { + configArea = ConfigArea.GENERAL; + } + } + this.configArea = configArea; + Store.selectedConfigArea = this.configArea; + super.requestUpdate(); + + } + + + /** + * Handles the footer `SHOW_SECTION` event. + * + * This will select the appropriate editor configuration section when a footer + * icon is clicked. + * + * @param args Event arguments that contain the section that was selected. + */ + protected OnFooterShowSection = (args: Event) => { + + // get the ConfigArea value for the active footer section. + const sectionToSelect = (args as CustomEvent).detail as Section; + const configArea = getConfigAreaForSection(sectionToSelect); + + // select the configuration area. + this.configArea = configArea; + + // store selected ConfigArea. + Store.selectedConfigArea = this.configArea; + } +} + + +customElements.define('spc-editor', CardEditor); diff --git a/src/editor/episode-fav-browser-editor.ts b/src/editor/episode-fav-browser-editor.ts new file mode 100644 index 0000000..796f331 --- /dev/null +++ b/src/editor/episode-fav-browser-editor.ts @@ -0,0 +1,127 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { Section } from '../types/section'; + + +const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'episodeFavBrowserTitle', + label: 'Section title text', + help: 'displayed at the top of the section', + required: false, + type: 'string', + }, + { + name: 'episodeFavBrowserSubTitle', + label: 'Section sub-title text', + help: 'displayed below the section title', + required: false, + type: 'string', + }, + { + name: 'episodeFavBrowserItemsPerRow', + label: '# of items to display per row', + help: 'use 1 for list format', + required: true, + type: 'integer', + default: 3, + valueMin: 1, + valueMax: 12, + }, + { + name: 'episodeFavBrowserItemsHideTitle', + label: 'Hide item row title text', + required: false, + selector: { boolean: {} }, + }, + { + name: 'episodeFavBrowserItemsHideSubTitle', + label: 'Hide item row sub-title text', + help: 'if Title visible', + required: false, + selector: { boolean: {} }, + }, + { + name: 'episodeFavBrowserItemsSortTitle', + label: 'Sort items by Title', + required: false, + selector: { boolean: {} }, + }, +]; + + +class EpisodeFavBrowserEditor 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` +
+ Settings that control the Episode Favorites 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-episode-fav-browser-editor', EpisodeFavBrowserEditor); diff --git a/src/editor/general-editor.ts b/src/editor/general-editor.ts new file mode 100644 index 0000000..53b39a1 --- /dev/null +++ b/src/editor/general-editor.ts @@ -0,0 +1,136 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { Section } from '../types/section'; +import { DOMAIN_MEDIA_PLAYER, DOMAIN_SPOTIFYPLUS } from '../constants'; + + +const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'sections', + label: 'Card sections to enable', + help: 'unchecked items will not be shown', + required: false, + type: 'multi_select', + options: { + /* the following must match defined names in `secion.ts` */ + player: 'Player', /* Section.PLAYER */ + albumfavorites: 'Album Favorites', /* Section.ALBUM_FAVORITES */ + artistfavorites: 'Artist Favorites', /* Section.ARTIST_FAVORITES */ + audiobookfavorites: 'Audiobook Favorites', /* Section.AUDIOBOOK_FAVORITES */ + devices: 'Devices', /* Section.DEVICES */ + episodefavorites: 'Episode Favorites', /* Section.EPISODE_FAVORITES */ + playlistfavorites: 'Playlist Favorites', /* Section.PLAYLIST_FAVORITES */ + recents: 'Recently Played', /* Section.RECENTS */ + searchmedia: 'Search Media', /* Section.SEARCH_MEDIA */ + showfavorites: 'Show Favorites', /* Section.SHOW_FAVORITES */ + trackfavorites: 'Track Favorites', /* Section.TRACK_FAVORITES */ + userpresets: 'User Presets', /* Section.USERPRESETS */ + }, + }, + { + name: 'entity', + label: 'SpotifyPlus media player entity to retrieve data from', + help: 'required', + required: true, + selector: { + entity: { + multiple: false, + filter: { + domain: DOMAIN_MEDIA_PLAYER, + integration: DOMAIN_SPOTIFYPLUS + } + } + }, + }, + { + name: 'title', + label: 'Card title text', + help: 'displayed at the top of the card above the section', + required: false, + type: 'string', + }, + { + name: 'width', + label: 'Width of the card', + help: 'in rem units; or "fill" for 100% width', + required: false, + type: 'string', + default: 35.15, + }, + { + name: 'height', + label: 'Height of the card', + help: 'in rem units; or "fill" for 100% height', + required: false, + type: 'string', + default: 35.15, + }, +// { +// name: 'imageUrlsReplaceHttpWithHttps', +// label: "Replace HTTP with HTTPS for image url's", +// required: false, +// selector: { boolean: {} }, +// }, +]; + + +class GeneralEditor 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` +
+ Settings that control the overall look and feel of the card +
+ + `; + } + + + /** + * 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 { + } + + `; + } + +} + +customElements.define('spc-general-editor', GeneralEditor); diff --git a/src/editor/player-controls-editor.ts b/src/editor/player-controls-editor.ts new file mode 100644 index 0000000..ad2372b --- /dev/null +++ b/src/editor/player-controls-editor.ts @@ -0,0 +1,100 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { Section } from '../types/section'; + + +const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'playerControlsHideShuffle', + label: 'Hide shuffle control button in the controls area', + required: false, + selector: { boolean: {} }, + }, + { + name: 'playerControlsHideTrackPrev', + label: 'Hide previous track control button in the controls area', + required: false, + selector: { boolean: {} }, + }, + { + name: 'playerControlsHidePlayPause', + label: 'Hide play / pause control button in the controls area', + required: false, + selector: { boolean: {} }, + }, + { + name: 'playerControlsHideTrackNext', + label: 'Hide next track control button in the controls area', + required: false, + selector: { boolean: {} }, + }, + { + name: 'playerControlsHideRepeat', + label: 'Hide repeat control button in the controls area', + required: false, + selector: { boolean: {} }, + }, + { + name: 'playerControlsHideFavorites', + label: 'Hide favorite actions control button in the controls area', + required: false, + selector: { boolean: {} }, + }, + { + name: 'playerControlsHide', + label: 'Hide controls area of the Player section form', + required: false, + selector: { boolean: {} }, + }, +]; + + +class PlayerControlsSettingsEditor 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 Media Control 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-controls-editor', PlayerControlsSettingsEditor); diff --git a/src/editor/player-editor.ts b/src/editor/player-editor.ts new file mode 100644 index 0000000..ea8f07f --- /dev/null +++ b/src/editor/player-editor.ts @@ -0,0 +1,114 @@ +// lovelace card imports. +import { css, html, nothing, TemplateResult } from 'lit'; +import { state } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; + +// our imports. +import { BaseEditor } from './base-editor'; +import './editor-form'; +import './player-header-editor'; +import './player-controls-editor'; +import './player-volume-editor'; + +/** Configuration area editor sections enum. */ +enum ConfigArea { + HEADER = 'Header', + CONTROLS = 'Controls', + VOLUME = 'Volume', +} + +/** Configuration area editor section keys array. */ +const { HEADER, CONTROLS, VOLUME } = ConfigArea; + + +class PlayerSettingsEditor extends BaseEditor { + + @state() private configArea = HEADER; + + /** + * 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(); + + return html` +
+ Settings that control the Player section look and feel +
+ + ${[HEADER, CONTROLS, VOLUME].map( + (configArea) => html` + this.OnConfigPlayerSectionClick(configArea)} + > + ${configArea} + + `, + )} + + + ${this.subEditor()} + `; + } + + + private subEditor() { + + // show the desired section editor. + return choose(this.configArea, [ + [ + HEADER, + () => html``, + ], + [ + CONTROLS, + () => html``, + ], + [ + VOLUME, + () => html``, + ], + ]); + } + + + /** + * Handles the `click` event fired when an editor section button is clicked. + * + * This will set the configArea attribute, which will display the selected editor section settings. + * + * @param args Event arguments that contain the configArea that was clicked on. + */ + private OnConfigPlayerSectionClick(configArea: ConfigArea) { + + // show the section editor form. + this.configArea = configArea; + } + + + /** + * 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); + } + + ha-control-button[selected] { + --control-button-background-color: var(--primary-color); + } + `; + } +} + +customElements.define('spc-player-editor', PlayerSettingsEditor); diff --git a/src/editor/player-header-editor.ts b/src/editor/player-header-editor.ts new file mode 100644 index 0000000..8fe869c --- /dev/null +++ b/src/editor/player-header-editor.ts @@ -0,0 +1,103 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { Section } from '../types/section'; +import { PLAYER_CONTROLS_BACKGROUND_COLOR_DEFAULT } from '../sections/player'; + + +const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'playerHeaderTitle', + label: 'Section title text displayed in the header area', + required: false, + type: 'string', + }, + { + name: 'playerHeaderArtistTrack', + label: 'Artist and Track info displayed in the header area', + required: false, + type: 'string', + }, + { + name: 'playerHeaderAlbum', + label: 'Album info displayed in the header area', + required: false, + type: 'string', + }, + { + name: 'playerHeaderNoMediaPlayingText', + label: 'Text to display in the header area when no media is currently playing', + required: false, + type: 'string', + }, + { + name: 'playerHeaderBackgroundColor', + label: 'Color value (e.g. "#hhrrggbb") for header area background gradient', + help: "'transparent' to disable", + required: false, + type: 'string', + default: PLAYER_CONTROLS_BACKGROUND_COLOR_DEFAULT, + }, + { + name: 'playerHeaderHideProgressBar', + label: 'Hide progress bar in the header area', + required: false, + selector: { boolean: {} }, + }, + { + name: 'playerHeaderHide', + label: 'Hide header area of the Player section form', + required: false, + selector: { boolean: {} }, + }, +]; + + +class PlayerHeaderSettingsEditor 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 Header Status 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-header-editor', PlayerHeaderSettingsEditor); diff --git a/src/editor/player-volume-editor.ts b/src/editor/player-volume-editor.ts new file mode 100644 index 0000000..99dabec --- /dev/null +++ b/src/editor/player-volume-editor.ts @@ -0,0 +1,85 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { Section } from '../types/section'; +import { PLAYER_CONTROLS_BACKGROUND_COLOR_DEFAULT } from '../sections/player'; + + +const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'playerControlsBackgroundColor', + label: 'Color value (e.g. "#hhrrggbb") for controls area background gradient', + help: "'transparent' to disable", + required: false, + type: 'string', + default: PLAYER_CONTROLS_BACKGROUND_COLOR_DEFAULT, + }, + { + name: 'playerVolumeControlsHideMute', + label: 'Hide mute button in the volume controls area', + required: false, + selector: { boolean: {} }, + }, + { + name: 'playerVolumeControlsHidePower', + label: 'Hide power button in the volume controls area', + required: false, + selector: { boolean: {} }, + }, + { + name: 'playerVolumeControlsHideSlider', + label: 'Hide volume slider in the volume controls area', + required: false, + selector: { boolean: {} }, + }, +]; + + +class PlayerVolumeSettingsEditor 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 Volume Control 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-volume-editor', PlayerVolumeSettingsEditor); diff --git a/src/editor/playlist-fav-browser-editor.ts b/src/editor/playlist-fav-browser-editor.ts new file mode 100644 index 0000000..4ba11ec --- /dev/null +++ b/src/editor/playlist-fav-browser-editor.ts @@ -0,0 +1,127 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { Section } from '../types/section'; + + +const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'playlistFavBrowserTitle', + label: 'Section title text', + help: 'displayed at the top of the section', + required: false, + type: 'string', + }, + { + name: 'playlistFavBrowserSubTitle', + label: 'Section sub-title text', + help: 'displayed below the section title', + required: false, + type: 'string', + }, + { + name: 'playlistFavBrowserItemsPerRow', + label: '# of items to display per row', + help: 'use 1 for list format', + required: true, + type: 'integer', + default: 3, + valueMin: 1, + valueMax: 12, + }, + { + name: 'playlistFavBrowserItemsHideTitle', + label: 'Hide item row title text', + required: false, + selector: { boolean: {} }, + }, + { + name: 'playlistFavBrowserItemsHideSubTitle', + label: 'Hide item row sub-title text', + help: 'if Title visible', + required: false, + selector: { boolean: {} }, + }, + { + name: 'playlistFavBrowserItemsSortTitle', + label: 'Sort items by Title', + required: false, + selector: { boolean: {} }, + }, +]; + + +class PlaylistFavBrowserEditor 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` +
+ Settings that control the Playlist Favorites 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-playlist-fav-browser-editor', PlaylistFavBrowserEditor); diff --git a/src/editor/recent-browser-editor.ts b/src/editor/recent-browser-editor.ts new file mode 100644 index 0000000..6dc48fc --- /dev/null +++ b/src/editor/recent-browser-editor.ts @@ -0,0 +1,105 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { Section } from '../types/section'; + +const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'recentBrowserTitle', + label: 'Section title text', + help: 'displayed at the top of the section', + required: false, + type: 'string', + }, + { + name: 'recentBrowserSubTitle', + label: 'Section sub-title text', + help: 'displayed below the section title', + required: false, + type: 'string', + }, + { + name: 'recentBrowserItemsPerRow', + label: '# of items to display per row', + help: 'use 1 for list format', + required: true, + type: 'integer', + default: 3, + valueMin: 1, + valueMax: 12, + }, + { + name: 'recentBrowserItemsHideTitle', + label: 'Hide item row title text', + required: false, + selector: { boolean: {} }, + }, + { + name: 'recentBrowserItemsHideSubTitle', + label: 'Hide item row sub-title text', + help: 'if Title visible', + required: false, + selector: { boolean: {} }, + }, +]; + + +class RecentSettingsEditor 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` +
+ Settings that control the Recently Played Browser 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 { + } + + `; + } + +} + +customElements.define('spc-recent-browser-editor', RecentSettingsEditor); diff --git a/src/editor/search-media-browser-editor.ts b/src/editor/search-media-browser-editor.ts new file mode 100644 index 0000000..b763d11 --- /dev/null +++ b/src/editor/search-media-browser-editor.ts @@ -0,0 +1,142 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { Section } from '../types/section'; + + +const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'searchMediaBrowserTitle', + label: 'Section title text', + help: 'displayed at the top of the section', + required: false, + type: 'string', + }, + { + name: 'searchMediaBrowserSubTitle', + label: 'Section sub-title text', + help: 'displayed below the section title', + required: false, + type: 'string', + }, + { + name: 'searchMediaBrowserSearchLimit', + label: 'Maximum # of items to return by the search', + required: false, + type: 'integer', + default: 50, + valueMin: 25, + valueMax: 500, + }, + { + name: 'searchMediaBrowserUseDisplaySettings', + label: 'Use search display settings when displaying results:', + required: false, + selector: { boolean: {} }, + }, + { + name: 'searchMediaBrowserItemsPerRow', + label: '# of items to display per row', + help: 'use 1 for list format', + required: true, + type: 'integer', + default: 3, + valueMin: 1, + valueMax: 12, + }, + { + name: 'searchMediaBrowserItemsHideTitle', + label: 'Hide item row title text', + required: false, + selector: { boolean: {} }, + }, + { + name: 'searchMediaBrowserItemsHideSubTitle', + label: 'Hide item row sub-title text', + help: 'if Title visible', + required: false, + selector: { boolean: {} }, + }, + //{ + // name: 'searchMediaBrowserItemsSortTitle', + // label: 'Sort items by Title', + // required: false, + // selector: { boolean: {} }, + //}, +]; + + +class SearchMediaBrowserEditor 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` +
+ Settings that control the Search Media 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-search-media-browser-editor', SearchMediaBrowserEditor); diff --git a/src/editor/show-fav-browser-editor.ts b/src/editor/show-fav-browser-editor.ts new file mode 100644 index 0000000..ea850dc --- /dev/null +++ b/src/editor/show-fav-browser-editor.ts @@ -0,0 +1,127 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { Section } from '../types/section'; + + +const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'showFavBrowserTitle', + label: 'Section title text', + help: 'displayed at the top of the section', + required: false, + type: 'string', + }, + { + name: 'showFavBrowserSubTitle', + label: 'Section sub-title text', + help: 'displayed below the section title', + required: false, + type: 'string', + }, + { + name: 'showFavBrowserItemsPerRow', + label: '# of items to display per row', + help: 'use 1 for list format', + required: true, + type: 'integer', + default: 3, + valueMin: 1, + valueMax: 12, + }, + { + name: 'showFavBrowserItemsHideTitle', + label: 'Hide item row title text', + required: false, + selector: { boolean: {} }, + }, + { + name: 'showFavBrowserItemsHideSubTitle', + label: 'Hide item row sub-title text', + help: 'if Title visible', + required: false, + selector: { boolean: {} }, + }, + { + name: 'showFavBrowserItemsSortTitle', + label: 'Sort items by Title', + required: false, + selector: { boolean: {} }, + }, +]; + + +class ShowFavBrowserEditor 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` +
+ Settings that control the Show Favorites 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-show-fav-browser-editor', ShowFavBrowserEditor); diff --git a/src/editor/track-fav-browser-editor.ts b/src/editor/track-fav-browser-editor.ts new file mode 100644 index 0000000..08b707c --- /dev/null +++ b/src/editor/track-fav-browser-editor.ts @@ -0,0 +1,127 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { Section } from '../types/section'; + + +const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'trackFavBrowserTitle', + label: 'Section title text', + help: 'displayed at the top of the section', + required: false, + type: 'string', + }, + { + name: 'trackFavBrowserSubTitle', + label: 'Section sub-title text', + help: 'displayed below the section title', + required: false, + type: 'string', + }, + { + name: 'trackFavBrowserItemsPerRow', + label: '# of items to display per row', + help: 'use 1 for list format', + required: true, + type: 'integer', + default: 3, + valueMin: 1, + valueMax: 12, + }, + { + name: 'trackFavBrowserItemsHideTitle', + label: 'Hide item row title text', + required: false, + selector: { boolean: {} }, + }, + { + name: 'trackFavBrowserItemsHideSubTitle', + label: 'Hide item row sub-title text', + help: 'if Title visible', + required: false, + selector: { boolean: {} }, + }, + { + name: 'trackFavBrowserItemsSortTitle', + label: 'Sort items by Title', + required: false, + selector: { boolean: {} }, + }, +]; + + +class TrackFavBrowserEditor 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` +
+ Settings that control the Track Favorites 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-track-fav-browser-editor', TrackFavBrowserEditor); diff --git a/src/editor/userpreset-browser-editor.ts b/src/editor/userpreset-browser-editor.ts new file mode 100644 index 0000000..7ea5e4a --- /dev/null +++ b/src/editor/userpreset-browser-editor.ts @@ -0,0 +1,111 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; + +// our imports. +import { BaseEditor } from './base-editor'; +import { Section } from '../types/section'; + + +const CONFIG_SETTINGS_SCHEMA = [ + { + name: 'userPresetBrowserTitle', + label: 'Section title text', + help: 'displayed at the top of the section', + required: false, + type: 'string', + }, + { + name: 'userPresetBrowserSubTitle', + label: 'Section sub-title text', + help: 'displayed below the section title', + required: false, + type: 'string', + }, + { + name: 'userPresetBrowserItemsPerRow', + label: '# of items to display per row', + help: 'use 1 for list format', + required: true, + type: 'integer', + default: 4, + valueMin: 1, + valueMax: 12, + }, + { + name: 'userPresetBrowserItemsHideTitle', + label: 'Hide item row title text', + required: false, + selector: { boolean: {} }, + }, + { + name: 'userPresetBrowserItemsHideSubTitle', + label: 'Hide item row sub-title text', + help: 'if Title visible', + required: false, + selector: { boolean: {} }, + }, +]; + + +class UserPresetSettingsEditor 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` +
+ Settings that control the User Preset Browser section look and feel +
+ +
+ User Preset items must be defined manually in the configuration code editor. + Please refer to the + wiki documentation for more details and examples. +
+ `; + } + + + /** + * 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 { + } + + `; + } + +} + +customElements.define('spc-userpreset-browser-editor', UserPresetSettingsEditor); diff --git a/src/events/editor-config-area-selected.ts b/src/events/editor-config-area-selected.ts new file mode 100644 index 0000000..d366453 --- /dev/null +++ b/src/events/editor-config-area-selected.ts @@ -0,0 +1,43 @@ +import { DOMAIN_SPOTIFYPLUS } from '../constants'; +import { Section } from '../types/section'; + +/** + * Uniquely identifies the event. + * */ +export const EDITOR_CONFIG_AREA_SELECTED = DOMAIN_SPOTIFYPLUS + '-card-editor-config-area-selected'; + + +/** + * Event arguments. + */ +export class EditorConfigAreaSelectedEventArgs { + + // property storage. + public section: Section; + + /** + * Initializes a new instance of the class. + * + * @param section Section that was selected. + */ + constructor(section?: Section) { + + this.section = section || Section.UNDEFINED; + } +} + + +/** + * Event constructor. + */ +export function EditorConfigAreaSelectedEvent(section:Section) { + + const args = new EditorConfigAreaSelectedEventArgs(); + args.section = section; + + return new CustomEvent(EDITOR_CONFIG_AREA_SELECTED, { + bubbles: true, + composed: true, + detail: args, + }); +} diff --git a/src/events/progress-ended.ts b/src/events/progress-ended.ts new file mode 100644 index 0000000..73f4fca --- /dev/null +++ b/src/events/progress-ended.ts @@ -0,0 +1,21 @@ +import { DOMAIN_SPOTIFYPLUS } from '../constants'; + +/** + * Uniquely identifies the event. + * */ +export const PROGRESS_ENDED = DOMAIN_SPOTIFYPLUS + '-card-progress-ended'; + + +/** + * Event constructor. + */ +export function ProgressEndedEvent() { + + // this event has no arguments. + return new CustomEvent(PROGRESS_ENDED, { + bubbles: true, + composed: true, + detail: {}, + }); + +} diff --git a/src/events/progress-started.ts b/src/events/progress-started.ts new file mode 100644 index 0000000..2c74ae6 --- /dev/null +++ b/src/events/progress-started.ts @@ -0,0 +1,21 @@ +import { DOMAIN_SPOTIFYPLUS } from '../constants'; + +/** + * Uniquely identifies the event. + * */ +export const PROGRESS_STARTED = DOMAIN_SPOTIFYPLUS + '-card-progress-started'; + + +/** + * Event constructor. + */ +export function ProgressStartedEvent() { + + // this event has no arguments. + return new CustomEvent(PROGRESS_STARTED, { + bubbles: true, + composed: true, + detail: {}, + }); + +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..a9de632 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,28 @@ +// our imports. +import { CARD_VERSION } from './constants'; +import './card'; + +// Good source of help documentation on HA custom cards: +// https://gist.github.com/thomasloven/1de8c62d691e754f95b023105fe4b74b + +// Display card version details in console, as well as a link to help docs. +console.groupCollapsed( + `%cSPOTIFYPLUS-CARD ${CARD_VERSION} IS INSTALLED`, + "color: green; font-weight: bold" +); +console.log( + "Wiki Docs:", + "https://github.com/thlucas1/spotifyplus_card/wiki/Configuration-Options" +); +console.groupEnd(); + +// Register our card for the card picker dialog in the HA UI dashboard +// by adding it to the "window.customCards" array with attributes that +// describe the card and what it provides ("type" and "name" are required). +window.customCards.push({ + type: 'spotifyplus-card', + name: 'SpotifyPlus Card', + description: 'Home Assistant UI card that supports features unique to the SpotifyPlus custom integration', + //documentationURL: 'https://github.com/thlucas1/spotifyplus_card/wiki/Configuration-Options', + preview: true, +}); diff --git a/src/model/media-player.ts b/src/model/media-player.ts new file mode 100644 index 0000000..1ff66fd --- /dev/null +++ b/src/model/media-player.ts @@ -0,0 +1,93 @@ +// our imports. +import { SpotifyPlusHassEntity } from '../types/spotifyplus-hass-entity'; +import { SpotifyPlusHassEntityAttributes } from '../types/spotifyplus-hass-entity-attributes'; +import { MediaPlayerEntityFeature, MediaPlayerState } from '../services/media-control-service'; + + +/** + * SpotifyPlus MediaPlayer class. + * + * An instance of this class is created from Home Assistant state data that + * represents a SpotifyPlusHassEntity type. It contains all attributes + * of a HASS entity and HASS MediaPlayer, as well as the custom attributes + * created by the SpotifyPlus integration MediaPlayer. + */ +export class MediaPlayer { + + attributes: SpotifyPlusHassEntityAttributes; + id: string; + name: string; + state: MediaPlayerState; + + /** + * Initializes a new instance of the class. + * + * @param hassEntity Home Assistant state data that represents a SpotifyPlusHassEntity type. + */ + constructor(hassEntity: SpotifyPlusHassEntity) { + + // initialize storage. + this.id = hassEntity.entity_id; + this.state = hassEntity.state as MediaPlayerState; + this.attributes = hassEntity.attributes; + this.name = this.attributes.friendly_name || ''; + } + + + /** + * Returns the current volume of the player as a percentage value (e.g. 0-100). + */ + public getVolume() { + if (this.attributes.volume_level) { + return 100 * this.attributes.volume_level; + } else { + return 0; + } + } + + + /** + * Returns true if the player is currently playing something (e.g. state = 'playing'); + * otherwise, false. + */ + public isPlaying() { + return this.state === MediaPlayerState.PLAYING; + } + + + /** + * Returns true if the player is currently powered off (e.g. state = 'off'); + * otherwise, false. + */ + public isPoweredOff() { + return this.state === MediaPlayerState.OFF; + } + + + /** + * Returns true if the player is currently powered off (e.g. state = 'off'); + * otherwise, false. + */ + public isPoweredOffOrUnknown() { + return this.state === MediaPlayerState.OFF || this.state === MediaPlayerState.UNKNOWN; + } + + + /** + * Returns true if the player volume is currently muted; + * otherwise, false. + */ + public isMuted(): boolean { + return this.attributes.is_volume_muted || false; + } + + + /** + * Returns true if the player supports requested feature; + * otherwise, false. + */ + public supportsFeature(feature: MediaPlayerEntityFeature) { + return ((this.attributes.supported_features || 0) & feature) == feature; + } + +} diff --git a/src/model/store.ts b/src/model/store.ts new file mode 100644 index 0000000..4257d5d --- /dev/null +++ b/src/model/store.ts @@ -0,0 +1,104 @@ +// lovelace card imports. +import { HomeAssistant } from 'custom-card-helpers'; + +// our imports. +import { HassService } from '../services/hass-service'; +import { MediaControlService } from '../services/media-control-service'; +import { SpotifyPlusService } from '../services/spotifyplus-service'; +import { Card } from '../card'; +import { BaseEditor } from '../editor/base-editor'; +import { CardConfig } from '../types/card-config'; +import { ConfigArea } from '../types/config-area'; +import { Section } from '../types/section'; +import { MediaPlayer } from './media-player'; + + +/** + * Card storage class instance. + * + * This class is used to store references to common services and data areas + * that are used by the various card sections. + * */ +export class Store { + + /** Home Assistant instance. */ + public hass: HomeAssistant; + + /** Card configuration data. */ + public config: CardConfig; + + /** Custom card instance. */ + public readonly card: Card | BaseEditor; + + /** Home Assistant services helper instance. */ + public hassService: HassService; + + /** SpotifyPlus services helper instance. */ + public spotifyPlusService: SpotifyPlusService; + + /** MediaControlService services helper instance. */ + public mediaControlService: MediaControlService; + + /** SpotifyPlus MediaPlayer object that will process requests. */ + public player: MediaPlayer; + + /** Currently selected section. */ + public section: Section; + + /** Currently selected ConfigArea **/ + static selectedConfigArea: ConfigArea = ConfigArea.GENERAL; + + /** card editor render flags for individual sections. */ + static hasCardEditLoadedMediaList: { [key: string]: boolean } = {}; + + + /** + * Initializes a new instance of the class. + * + * @param hass Home Assistant instance. + * @param config Card configuration data. + * @param card Custom card instance. + * @param section Currently selected section of the card. + * @param playerId Entity ID of the SpotifyPlus device that will process requests. + */ + constructor(hass: HomeAssistant, config: CardConfig, card: Card | BaseEditor, section: Section, playerId: string) { + + // if hass property has not been set yet, then it's a programmer problem! + if (!hass) { + throw new Error("STPC0005 hass property has not been set!"); + } + + // initialize storage. + this.hass = hass; + this.config = config; + this.card = card; + this.hassService = new HassService(hass); + this.mediaControlService = new MediaControlService(this.hassService); + this.spotifyPlusService = new SpotifyPlusService(hass, card); + this.player = this.getMediaPlayerObject(playerId); + this.section = section; + } + + + /** + * Returns a MediaPlayer object for the given entity id value. + * + * @param entityId Entity ID of the media player. + * @returns A MediaPlayer object. + * @throws Error if the specified entityId values does not exist in the hass.states data. + */ + public getMediaPlayerObject(entityId: string) { + + // does entity id exist in hass state data? + const hassEntity = Object.values(this.hass.states) + .filter((ent) => 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"); + + // convert the hass state representation to a media player object. + return new MediaPlayer(hassEntity[0]); + } + +} diff --git a/src/sections/album-fav-browser.ts b/src/sections/album-fav-browser.ts new file mode 100644 index 0000000..8a2e505 --- /dev/null +++ b/src/sections/album-fav-browser.ts @@ -0,0 +1,201 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +// our imports. +import '../components/media-browser-list'; +import '../components/media-browser-icons'; +import '../components/album-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 { IAlbum } from '../types/spotifyplus/album'; +import { GetAlbums } from '../types/spotifyplus/album-page-saved'; + + +@customElement("spc-album-fav-browser") +export class AlbumFavBrowser extends FavBrowserBase { + + /** Array of items to display in the media list. */ + protected override mediaList!: Array | undefined; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.ALBUM_FAVORITES); + this.filterCriteriaPlaceholder = "filter by album 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. + const title = formatTitleInfo(this.config.albumFavBrowserTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + const subtitle = formatTitleInfo(this.config.albumFavBrowserSubTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + + // render html. + return html` +
+ ${title ? html`
${title}
` : html``} + ${subtitle ? html`
${subtitle}
` : html``} +
+ ${!(this.isActionsVisible || false) ? html`` : html`${this.btnHideActionsHtml}`} + ${this.filterCriteriaHtml}${this.refreshMediaListHtml} +
+
+ ${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""} + ${(() => { + // if actions are not visbile, then render the media list. + if (!this.isActionsVisible) { + const filterName = (this.filterCriteria || "").toLocaleLowerCase(); + if (this.config.albumFavBrowserItemsPerRow === 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} + >` + ) + } + // if actions are visbile, then render the actions display. + } else { + return html``; + } + })()} +
+
+ `; + } + + + /** + * 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. + */ + 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>(); + + // create promise - get media list. + const promiseUpdateMediaList = new Promise((resolve, reject) => { + + // set service parameters. + const limitTotal = this.LIMIT_TOTAL_MAX; + const sortResult = this.config.albumFavBrowserItemsSortTitle || false; + const market = null; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetAlbumFavorites(player.id, 0, 0, market, limitTotal, sortResult) + .then(result => { + + // load media list results. + this.mediaList = GetAlbums(result); + this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + + // resolve the promise. + 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 Album Favorites failed: \n" + (error as Error).message); + + // reject the promise. + reject(error); + + }) + }); + + promiseRequests.push(promiseUpdateMediaList); + + // 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("Album favorites refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} \ No newline at end of file diff --git a/src/sections/artist-fav-browser.ts b/src/sections/artist-fav-browser.ts new file mode 100644 index 0000000..02d12ef --- /dev/null +++ b/src/sections/artist-fav-browser.ts @@ -0,0 +1,199 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +// our imports. +import '../components/media-browser-list'; +import '../components/media-browser-icons'; +import '../components/artist-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 { IArtist } from '../types/spotifyplus/artist'; + + +@customElement("spc-artist-fav-browser") +export class ArtistFavBrowser extends FavBrowserBase { + + /** Array of items to display in the media list. */ + protected override mediaList!: Array | undefined; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.ARTIST_FAVORITES); + this.filterCriteriaPlaceholder = "filter by artist 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. + const title = formatTitleInfo(this.config.artistFavBrowserTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + const subtitle = formatTitleInfo(this.config.artistFavBrowserSubTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + + // render html. + return html` +
+ ${title ? html`
${title}
` : html``} + ${subtitle ? html`
${subtitle}
` : html``} +
+ ${!(this.isActionsVisible || false) ? html`` : html`${this.btnHideActionsHtml}`} + ${this.filterCriteriaHtml}${this.refreshMediaListHtml} +
+
+ ${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""} + ${(() => { + // if actions are not visbile, then render the media list. + if (!this.isActionsVisible) { + const filterName = (this.filterCriteria || "").toLocaleLowerCase(); + if (this.config.artistFavBrowserItemsPerRow === 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} + >` + ) + } + // if actions are visbile, then render the actions display. + } else { + return html``; + } + })()} +
+
+ `; + } + + + /** + * 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. + */ + 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>(); + + // create promise - get media list. + const promiseUpdateMediaList = new Promise((resolve, reject) => { + + // set service parameters. + const limitTotal = this.LIMIT_TOTAL_MAX; + const sortResult = this.config.artistFavBrowserItemsSortTitle || false; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetArtistsFollowed(player.id, 0, 0, limitTotal, sortResult) + .then(result => { + + // load media list results. + this.mediaList = result.items; + this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + + // resolve the promise. + 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 Artist Followed failed: \n" + (error as Error).message); + + // reject the promise. + reject(error); + + }) + }); + + promiseRequests.push(promiseUpdateMediaList); + + // 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("Artist followed refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} \ No newline at end of file diff --git a/src/sections/audiobook-fav-browser.ts b/src/sections/audiobook-fav-browser.ts new file mode 100644 index 0000000..b7f0555 --- /dev/null +++ b/src/sections/audiobook-fav-browser.ts @@ -0,0 +1,199 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +// our imports. +import '../components/media-browser-list'; +import '../components/media-browser-icons'; +import '../components/audiobook-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 { IAudiobookSimplified } from '../types/spotifyplus/audiobook-simplified'; + + +@customElement("spc-audiobook-fav-browser") +export class AudiobookFavBrowser extends FavBrowserBase { + + /** Array of items to display in the media list. */ + protected override mediaList!: Array | undefined; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.AUDIOBOOK_FAVORITES); + this.filterCriteriaPlaceholder = "filter by audiobook 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. + const title = formatTitleInfo(this.config.audiobookFavBrowserTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + const subtitle = formatTitleInfo(this.config.audiobookFavBrowserSubTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + + // render html. + return html` +
+ ${title ? html`
${title}
` : html``} + ${subtitle ? html`
${subtitle}
` : html``} +
+ ${!(this.isActionsVisible || false) ? html`` : html`${this.btnHideActionsHtml}`} + ${this.filterCriteriaHtml}${this.refreshMediaListHtml} +
+
+ ${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""} + ${(() => { + // if actions are not visbile, then render the media list. + if (!this.isActionsVisible) { + const filterName = (this.filterCriteria || "").toLocaleLowerCase(); + if (this.config.audiobookFavBrowserItemsPerRow === 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} + >` + ) + } + // if actions are visbile, then render the actions display. + } else { + return html``; + } + })()} +
+
+ `; + } + + + /** + * 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. + */ + 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>(); + + // create promise - get media list. + const promiseUpdateMediaList = new Promise((resolve, reject) => { + + // set service parameters. + const limitTotal = this.LIMIT_TOTAL_MAX; + const sortResult = this.config.audiobookFavBrowserItemsSortTitle || false; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetAudiobookFavorites(player.id, 0, 0, limitTotal, sortResult) + .then(result => { + + // load media list results. + this.mediaList = result.items; + this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + + // resolve the promise. + 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 Audiobook Favorites failed: \n" + (error as Error).message); + + // reject the promise. + reject(error); + + }) + }); + + promiseRequests.push(promiseUpdateMediaList); + + // 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("Audiobook favorites refresh failed: \n" + (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 new file mode 100644 index 0000000..a236fa7 --- /dev/null +++ b/src/sections/device-browser.ts @@ -0,0 +1,248 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +// our imports. +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 { ISpotifyConnectDevice } from '../types/spotifyplus/spotify-connect-device'; + + +@customElement("spc-device-browser") +export class DeviceBrowser extends FavBrowserBase { + + /** Array of items to display in the media list. */ + protected override mediaList!: Array | undefined; + + /** True to refresh device list from real-time data; False to refresh from internal cache. */ + private refreshDeviceList?: boolean; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.DEVICES); + this.filterCriteriaPlaceholder = "filter by device 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. + const title = formatTitleInfo(this.config.deviceBrowserTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + const subtitle = formatTitleInfo(this.config.deviceBrowserSubTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + + // render html. + return html` +
+ ${title ? html`
${title}
` : html``} + ${subtitle ? html`
${subtitle}
` : html``} +
+ ${!(this.isActionsVisible || false) ? html`` : html`${this.btnHideActionsHtml}`} + ${this.filterCriteriaHtml}${this.refreshMediaListHtml} +
+
+ ${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""} + ${(() => { + // if actions are not visbile, then render the media list. + if (!this.isActionsVisible) { + const filterName = (this.filterCriteria || "").toLocaleLowerCase(); + if (this.config.deviceBrowserItemsPerRow === 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} + >` + ) + } + // if actions are visbile, then render the actions display. + } else { + return html``; + } + })()} +
+
+ `; + } + + + /** + * 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. + const action = (ev.currentTarget! as HTMLElement).getAttribute("action")!; + + if (action === "refresh") { + + // indicate we want a real-time list of devices. + this.refreshDeviceList = true; + + } + + // invoke base class method. + super.onFilterActionsClick(ev); + + } + + + /** + * Handles the `item-selected` event fired when a media browser item is clicked. + * + * @param args Event arguments that contain the media item that was clicked on. + */ + protected override onItemSelected = (args: CustomEvent) => { + + const mediaItem = args.detail; + this.SelectSource(mediaItem); + + }; + + + /** + * Calls the mediaplayer select_source service to select a source. + * + * @param mediaItem The medialist item that was selected. + */ + private async SelectSource(mediaItem: ISpotifyConnectDevice) { + + // call service to select the source. + await this.store.mediaControlService.select_source(this.player, mediaItem.Name || ''); + + } + + + + + /** + * 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>(); + + // create promise - get media list. + const promiseUpdateMediaList = new Promise((resolve, reject) => { + + // set service parameters. + const refresh = this.refreshDeviceList || false; // refresh device list (defaults to cached list). + const sortResult = true; // true to sort returned items; otherwise, false + + // call the service to retrieve the media list. + this.spotifyPlusService.GetSpotifyConnectDevices(player.id, refresh, sortResult) + .then(result => { + + // load media list results. + this.mediaList = result.Items; + this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + + // resolve the promise. + 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 Spotify Connect Devices failed: \n" + (error as Error).message); + + // reject the promise. + reject(error); + + }) + }); + + promiseRequests.push(promiseUpdateMediaList); + + // 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("Spotify Connect Device refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} \ No newline at end of file diff --git a/src/sections/episode-fav-browser.ts b/src/sections/episode-fav-browser.ts new file mode 100644 index 0000000..f2a54fd --- /dev/null +++ b/src/sections/episode-fav-browser.ts @@ -0,0 +1,200 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +// our imports. +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 { GetEpisodes } from '../types/spotifyplus/episode-page-saved'; + + +@customElement("spc-episode-fav-browser") +export class EpisodeFavBrowser extends FavBrowserBase { + + /** Array of items to display in the media list. */ + protected override mediaList!: Array | undefined; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.EPISODE_FAVORITES); + this.filterCriteriaPlaceholder = "filter by episode 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. + const title = formatTitleInfo(this.config.episodeFavBrowserTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + const subtitle = formatTitleInfo(this.config.episodeFavBrowserSubTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + + // render html. + return html` +
+ ${title ? html`
${title}
` : html``} + ${subtitle ? html`
${subtitle}
` : html``} +
+ ${!(this.isActionsVisible || false) ? html`` : html`${this.btnHideActionsHtml}`} + ${this.filterCriteriaHtml}${this.refreshMediaListHtml} +
+
+ ${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""} + ${(() => { + // if actions are not visbile, then render the media list. + if (!this.isActionsVisible) { + const filterName = (this.filterCriteria || "").toLocaleLowerCase(); + if (this.config.episodeFavBrowserItemsPerRow === 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} + >` + ) + } + // if actions are visbile, then render the actions display. + } else { + return html``; + } + })()} +
+
+ `; + } + + + /** + * 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. + */ + 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>(); + + // create promise - get media list. + const promiseUpdateMediaList = new Promise((resolve, reject) => { + + // set service parameters. + const limitTotal = this.LIMIT_TOTAL_MAX; // max # of items to return + const sortResult = this.config.episodeFavBrowserItemsSortTitle || false; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetEpisodeFavorites(player.id, 0, 0, limitTotal, sortResult) + .then(result => { + + // load media list results. + this.mediaList = GetEpisodes(result); + this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + + // resolve the promise. + 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 Episode Favorites failed: \n" + (error as Error).message); + + // reject the promise. + reject(error); + + }) + }); + + promiseRequests.push(promiseUpdateMediaList); + + // 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("Episode favorites refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} \ No newline at end of file diff --git a/src/sections/fav-browser-base.ts b/src/sections/fav-browser-base.ts new file mode 100644 index 0000000..4b65193 --- /dev/null +++ b/src/sections/fav-browser-base.ts @@ -0,0 +1,718 @@ +// lovelace card imports. +import { html, LitElement, PropertyValues, TemplateResult } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { HomeAssistant } from 'custom-card-helpers'; +import { + mdiArrowLeft, + mdiRefresh, +} from '@mdi/js'; + +// our imports. +import { CardConfig } from '../types/card-config'; +import { Section } from '../types/section'; +import { Store } from '../model/store'; +import { MediaPlayer } from '../model/media-player'; +import { SpotifyPlusService } from '../services/spotifyplus-service'; +import { storageService } from '../decorators/storage'; +import { truncateMediaList } from '../utils/media-browser-utils'; +import { isCardInEditPreview } from '../utils/utils'; +import { ProgressEndedEvent } from '../events/progress-ended'; +import { ProgressStartedEvent } from '../events/progress-started'; +import { DOMAIN_SPOTIFYPLUS } from '../constants'; + +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from '../constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":fav-browser-base"); + +/** Keys used to access cached storage items. */ +const CACHE_KEY_FILTER_CRITERIA = "_filtercriteria"; +const CACHE_KEY_MEDIA_LIST = "_medialist"; +const CACHE_KEY_MEDIA_LIST_LAST_UPDATED = "_medialistlastupdated"; + +const ERROR_REFRESH_IN_PROGRESS = "Previous refresh is still in progress - please wait"; + + +export class FavBrowserBase extends LitElement { + + // public state properties. + @property({ attribute: false }) public hass!: HomeAssistant; + @property({ attribute: false }) protected store!: Store; + + // private state properties. + @state() protected alertError?: string; + @state() protected alertInfo?: string; + @state() protected isActionsVisible?: boolean; + @state() protected scrollTopSaved?: number; + @state() protected mediaItem?: any; + @state() protected filterCriteria?: string; + + // html form element objects. + @query("#mediaBrowserContentElement", true) protected mediaBrowserContentElement!: HTMLDivElement; + @query("#filterCriteria", true) protected filterCriteriaElement!: HTMLElement; + + /** Card configuration data. */ + protected config!: CardConfig; + + /** MediaPlayer instance created from the configuration entity id. */ + protected player!: MediaPlayer; + + /** True if media list is currently being updated / waiting for an update; otherwise, false. */ + protected isUpdateInProgress!: boolean; + + /** True if the card is in edit preview mode (e.g. being edited); otherwise, false. */ + protected isCardInEditPreview!: boolean; + + /** Date and time (in epoch format) of when the media list was last updated. */ + protected mediaListLastUpdatedOn!: number; + + /** Array of items to display in the media list. */ + protected mediaList!: Array | undefined; + + /** Type of media being accessed. */ + protected mediaType!: Section; + + /** Filter criteria placeholder value. */ + protected filterCriteriaPlaceholder?: string; + + /** SpotifyPlus services instance. */ + protected spotifyPlusService!: SpotifyPlusService; + + /** Base key used to access cached storage items. */ + protected cacheKeyBase?: string; + + /** 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. */ + protected LIMIT_TOTAL_MAX = 200; + + protected filterCriteriaHtml; + protected refreshMediaListHtml; + protected btnHideActionsHtml; + + // bound event listeners for event handlers that need access to "this" object. + private onKeyDown_EventListenerBound; + + + /** + * Initializes a new instance of the class. + * + * @mediaType Type of media section that is being displayed. + */ + constructor(mediaType: Section) { + + // invoke base class method. + super(); + + // initialize storage. + this.isUpdateInProgress = false; + this.mediaType = mediaType; + + // create bound event listeners for event handlers that need access to "this" object. + this.onKeyDown_EventListenerBound = this.onKeyDown.bind(this); + + } + + + /** + * 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 { + + if (debuglog.enabled) { + debuglog("render - rendering control: %s\n- mediaListLastUpdatedOn = %s\n- scrollTopSaved = %s", + JSON.stringify(this.mediaType), + JSON.stringify(this.mediaListLastUpdatedOn), + JSON.stringify(this.scrollTopSaved), + ); + } + + // set common references from application common storage area. + this.hass = this.store.hass + this.config = this.store.config; + this.player = this.store.player; + this.spotifyPlusService = this.store.spotifyPlusService; + + // set scroll position (if needed). + this.setScrollPosition(); + + // define control to render - search criteria. + this.filterCriteriaHtml = html` + + `; + + // define control to render - search icon. + this.refreshMediaListHtml = html` + + `; + + // define control to render - back icon. + this.btnHideActionsHtml = html` + + `; + + // all html is rendered in the inheriting class. + } + + + ///** + // * style definitions used by this component. + // * */ + //static get styles() { + + // return [ + // //sharedStylesFavBrowser, + // css` + // ` + // ]; + //} + + + /** + * Invoked when the component is added to the document's DOM. + * + * In `connectedCallback()` you should setup tasks that should only occur when + * the element is connected to the document. The most common of these is + * adding event listeners to nodes external to the element, like a keydown + * event handler added to the window. + */ + public connectedCallback() { + + // invoke base class method. + super.connectedCallback(); + + // add document level event listeners. + document.addEventListener("keydown", this.onKeyDown_EventListenerBound); + + // determine if card configuration is being edited. + this.isCardInEditPreview = isCardInEditPreview(this.store.card); + + } + + + /** + * Invoked when the component is removed from the document's DOM. + * + * This callback is the main signal to the element that it may no longer be + * used. `disconnectedCallback()` should ensure that nothing is holding a + * reference to the element (such as event listeners added to nodes external + * to the element), so that it is free to be garbage collected. + * + * An element may be re-connected after being disconnected. + */ + public disconnectedCallback() { + + // remove document level event listeners. + document.removeEventListener("keydown", this.onKeyDown_EventListenerBound); + + // invoke base class method. + super.disconnectedCallback(); + } + + + /** + * 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 { + + // ** IMPORTANT ** + // if editing the card in the configuration editor ... + // this method will fire every time the configuration changes! for example, the + // method will execute for every keystroke if you are typing something into a + // configuration editor field! + + // invoke base class method. + super.firstUpdated(changedProperties); + + //console.log("firstUpdated (fav-browser-base) - changedProperties keys:\n- %s", + // JSON.stringify(Array.from(changedProperties.keys())), + //); + + // set storage cache key for the media player user. + // the prefix will include our domain, the Spotify user name, and the storage key. + // this allows us to maintain preferences for multiple Spotify accounts. + this.cacheKeyBase = DOMAIN_SPOTIFYPLUS + "_" + (this.player.attributes.sp_user_id || "nospuserid") + "_" + + // loads values from persistant storage. + this.storageValuesLoad() + + // if cache was empty, then we will retrieve the media list via the `updateMediaList` method. + if ((this.mediaListLastUpdatedOn || 0) == 0) { + + // if we are editing the card configuration, then we only want to do this one time! + // this is because the `firstUpdated` method will fire every time the configuration changes! + // if we already updated the media list, then don't do it again. + if ((this.isCardInEditPreview) && (this.mediaType in Store.hasCardEditLoadedMediaList)) { + if (debuglog.enabled) { + debuglog("%c firstUpdated - cache is empty, and we already called updateMediaList to retrieve media list; will not update again while editing card!", + "color: yellow;", + ); + } + return; + } + + if (debuglog.enabled) { + debuglog("%c firstUpdated - cache is empty; updating media list on first update", + "color: yellow;", + ); + } + + // refresh the medialist. + this.updateMediaList(this.player); + + } else { + + // at this point, the media list was loaded from the cache. + // if editing card configuration then truncate the media list so that the UI stays + // responsive while changes are being made; only display the truncation message once. + if (this.isCardInEditPreview) { + this.updatedMediaListOk(false); + } + + } + + } + + + /** + * Loads values from persistant storage. + */ + protected storageValuesLoad() { + + // load media list and supporting values from the cache. + this.mediaListLastUpdatedOn = storageService.getStorageValue(this.cacheKeyBase + this.mediaType + CACHE_KEY_MEDIA_LIST_LAST_UPDATED, 0); + this.mediaList = storageService.getStorageValue(this.cacheKeyBase + this.mediaType + CACHE_KEY_MEDIA_LIST, undefined); + this.filterCriteria = storageService.getStorageValue(this.cacheKeyBase + this.mediaType + CACHE_KEY_FILTER_CRITERIA, undefined); + + if (debuglog.enabled) { + debuglog("storageValuesLoad - parameters loaded from cache: mediaListLastUpdatedOn, mediaList, filterCriteria"); + } + + } + + + /** + * Saves values to persistant storage. + */ + protected storageValuesSave() { + + // save media list and supporting values to the cache. + storageService.setStorageValue(this.cacheKeyBase + this.mediaType + CACHE_KEY_MEDIA_LIST_LAST_UPDATED, this.mediaListLastUpdatedOn); + storageService.setStorageValue(this.cacheKeyBase + this.mediaType + CACHE_KEY_MEDIA_LIST, this.mediaList); + storageService.setStorageValue(this.cacheKeyBase + this.mediaType + CACHE_KEY_FILTER_CRITERIA, this.filterCriteria); + + if (debuglog.enabled) { + debuglog("storageValuesSave - parameters saved to cache: mediaListLastUpdatedOn, mediaList, filterCriteria"); + } + + } + + + /** + * Clears the error and informational alert text. + */ + protected alertClear() { + this.alertInfo = undefined; + this.alertError = undefined; + } + + + /** + * Clears the error alert text. + */ + protected alertErrorClear() { + this.alertError = undefined; + } + + + /** + * Sets the alert error message, and clears the informational alert message. + */ + protected alertErrorSet(message: string): void { + this.alertError = message; + this.alertInfoClear(); + } + + + /** + * Clears the informational alert text. + */ + protected alertInfoClear() { + this.alertInfo = undefined; + } + + + /** + * Hide visual progress indicator. + */ + protected progressHide(): void { + this.store.card.dispatchEvent(ProgressEndedEvent()); + this.isUpdateInProgress = false; + } + + + /** + * Show visual progress indicator. + */ + protected progressShow(): void { + this.store.card.dispatchEvent(ProgressStartedEvent()); + } + + + protected onFilterCriteriaChange(ev: CustomEvent) { + + // store search critera. + this.filterCriteria = ev.detail.value; + + } + + + protected onFilterCriteriaKeyPress(ev) { + + // was ENTER pressed? if so, then refresh the media list. + if (ev.key === "Enter") { + this.updateMediaList(this.player); + } + + } + + + protected onFilterActionsClick(ev: MouseEvent) { + + // get action to perform. + const action = (ev.currentTarget! as HTMLElement).getAttribute("action")!; + + if (action === "refresh") { + + // clear cache if user chose to manually refresh the media list. + storageService.clearStorageValue(this.cacheKeyBase + this.mediaType + CACHE_KEY_MEDIA_LIST_LAST_UPDATED); + storageService.clearStorageValue(this.cacheKeyBase + this.mediaType + CACHE_KEY_MEDIA_LIST); + + // clear first time media list load for card editing logic. + if (this.mediaType in Store.hasCardEditLoadedMediaList) { + delete Store.hasCardEditLoadedMediaList[this.mediaType]; + } + + // refresh the media list. + this.updateMediaList(this.player); + + } else if (action === "hideactions") { + + // hide actions container. + this.isActionsVisible = false; + + // 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); + + } + + } + + + /** + * Handles the `item-selected` event fired when a media browser item is clicked. + * + * @param args Event arguments that contain the media item that was clicked on. + */ + protected onItemSelected = (args: CustomEvent) => { + + if (debuglog.enabled) { + debuglog("onItemSelected - media item selected:\n%s", + JSON.stringify(args.detail, null, 2), + ); + } + + const mediaItem = args.detail; + this.PlayMediaItem(mediaItem); + + } + + + /** + * 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 onItemSelectedWithHold = (args: CustomEvent) => { + + if (debuglog.enabled) { + debuglog("onItemSelectedWithHold - media item selected:\n%s", + JSON.stringify(args.detail, null, 2), + ); + } + + // do not display actions if editing card configuration. + if (this.isCardInEditPreview) { + this.alertInfo = "Cannot display actions while editing card configuration"; + return; + } + + // save the selected media item reference. + this.mediaItem = args.detail; + + // save scroll position. + this.scrollTopSaved = this.mediaBrowserContentElement.scrollTop; + + // toggle action visibility. + this.isActionsVisible = !this.isActionsVisible; + + }; + + + /** + * KeyDown event handler. + * + * @ev Event arguments. + */ + private onKeyDown(ev: KeyboardEvent) { + + // was ESCAPE pressed? + if (ev.key === "Escape") { + + // hide actions container. + this.isActionsVisible = false; + + // 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); + } + + } + + + /** + * Calls the SpotifyPlusService Card_PlayMediaBrowserItem method to play media. + * + * @param mediaItem The medialist item that was selected. + */ + protected async PlayMediaItem(mediaItem: any): Promise { + + try { + + // show progress indicator. + this.progressShow(); + + // play media item. + await this.spotifyPlusService.Card_PlayMediaBrowserItem(this.player, mediaItem); + + // 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(); + + } + } + + + /** + * Sets the scroll position on the media list content container. + */ + protected setScrollPosition() { + + // are actions displayed? if so, then don't bother. + if (this.isActionsVisible) { + return; + } + + // have we already updated the renderRoot styles? if so, then we are done. + if (this.scrollTopSaved == 0) { + return; + } + + // if media browser list has not completely updated yet then we can't do anything. + if (!this.hasUpdated) { + return; + } + + // has media browser list shadowRoot been rendered? if not, then we can't do anything yet. + const elmMediaList = this.shadowRoot?.querySelector(".media-browser-list") as LitElement; + if (!elmMediaList) { + return; + } + + // at this point, elmMediaList should one of the following elements since they both + // utilize the "media-browser-list" class. Which one is controlled by user configuration + // settings "xItemsPerRow" parameter (1=list format, 2+=icons format): + // - "" element + // - "" element + + // if shadowRoot has not updated yet then we can't do anything. + if (!elmMediaList.shadowRoot) { + return; + } + + // if has not completely updated yet then we can't do anything. + if (!elmMediaList?.updateComplete) { + return; + } + + // set the vertical scroll position. + this.mediaBrowserContentElement.scrollTop = this.scrollTopSaved || 0; + + // 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. + // we will also indicate that the scroll position has been updated, so we don't do it again. + setTimeout(() => { + this.setScrollPosition(); + this.scrollTopSaved = 0; + }, 50); + + } + + + /** + * Updates the mediaList display. + * + * @returns False if the media list should not be updated; otherwise, True to update the media list. + */ + protected updateMediaList(player: MediaPlayer): boolean { + + // check if update is already in progress. + if (!this.isUpdateInProgress) { + this.isUpdateInProgress = true; + } else { + this.alertErrorSet(ERROR_REFRESH_IN_PROGRESS); + return false; + } + + // if player reference not set then we are done. + if (!player) { + this.isUpdateInProgress = false; + this.alertErrorSet("Player reference not set in updateMediaList"); + return false; + } + + // hide actions section (in case it is displayed). + this.isActionsVisible = false; + + // reset scroll top position, as we are generating a new list. + this.mediaBrowserContentElement.scrollTop = 0; + this.scrollTopSaved = 0; + + // prepare to update the media list. + // only get the `mediaListLastUpdatedOn` from cache for now, as we don't know if we are + // refreshing the list or if we are using the cached list (if one exists). + this.mediaListLastUpdatedOn = storageService.getStorageValue(this.cacheKeyBase + this.mediaType + CACHE_KEY_MEDIA_LIST_LAST_UPDATED, 0); + + // no need to refresh the media list if card is being edited and a cached media list was found. + if ((this.isCardInEditPreview) && (this.mediaListLastUpdatedOn != 0)) { + this.isUpdateInProgress = false; + return false; + } + + // clear alerts. + this.alertClear(); + + if (debuglog.enabled) { + debuglog("%c updateMediaList - updating medialist", + "color: yellow;", + ); + } + + // indicate caller can refresh it's media list. + return true; + } + + + /** + * Should be called if the media list was updated successfully. + * + * @param updateCache True (default) to cache media list parameters and results; otherwise, False not to update the cache. + */ + protected updatedMediaListOk(updateCache: boolean = true): void { + + // clear certain error messsages if they are temporary. + if (this.alertError == ERROR_REFRESH_IN_PROGRESS) { + this.alertErrorClear(); + } + + // if no items then update status. + if ((this.mediaList) && (this.mediaList.length == 0)) { + this.alertInfo = "No items found"; + } + + // cache media list parameters and results. + if (updateCache) { + this.storageValuesSave(); + } + + // if editing the card, then indicate the media list has been loaded. + // we will also truncate the list of items if neccessary, to keep the ui + // responsive while editing the card. + if (this.isCardInEditPreview) { + const infoMsg = truncateMediaList(this.mediaList, this.EDITOR_LIMIT_TOTAL_MAX); + if (!(this.mediaType in Store.hasCardEditLoadedMediaList)) { + this.alertInfo = infoMsg; + } else { + } + Store.hasCardEditLoadedMediaList[this.mediaType] = true; + } + + } + + + /** + * Should be called if an error occured while trying to update a media list. + */ + protected updatedMediaListError( + alertErrorMessage: string | null = null, + ): void { + + // clear informational alerts. + this.alertInfoClear(); + + if (debuglog.enabled) { + debuglog("updatedMediaListError - %s", + JSON.stringify(alertErrorMessage), + ); + } + + // set alert status text. + this.alertErrorSet(alertErrorMessage || "Unknown Error"); + + } + +} \ No newline at end of file diff --git a/src/sections/player.ts b/src/sections/player.ts new file mode 100644 index 0000000..2a8b10a --- /dev/null +++ b/src/sections/player.ts @@ -0,0 +1,484 @@ +// 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'; + +// ** IMPORTANT - Vibrant notes: +// ensure that you have "compilerOptions"."lib": [ ... , "WebWorker" ] specified +// in your tsconfig.json! If not, the Vibrant module will not initialize correctly +// and you will tear your hair out trying to figure out why it doesn't work!!! +import Vibrant from 'node-vibrant/dist/vibrant'; + +// our imports. +import '../components/player-header'; +import '../components/player-body-track'; +import '../components/player-body-show'; +import '../components/player-body-audiobook'; +import '../components/player-controls'; +import '../components/player-volume'; +import { CardConfig } from '../types/card-config'; +import { Store } from '../model/store'; +import { BRAND_LOGO_IMAGE_BASE64, BRAND_LOGO_IMAGE_SIZE } from '../constants'; +import { MediaPlayer } from '../model/media-player'; +import { HomeAssistantEx } from '../types/home-assistant-ex'; +import { Palette } from '@vibrant/color'; +import { isCardInEditPreview } from '../utils/utils'; +import { playerAlerts } from '../types/playerAlerts'; + +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from '../constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":player"); + +/** default color value of the player header / controls background gradient. */ +export const PLAYER_CONTROLS_BACKGROUND_COLOR_DEFAULT = '#000000BB'; + + +@customElement("spc-player") +export class Player extends LitElement implements playerAlerts { + + // public state properties. + @property({ attribute: false }) store!: Store; + + // private storage. + @state() private alertError?: string; + @state() private alertInfo?: string; + @state() private config!: CardConfig; + @state() private playerImage?: string; + @state() private _colorPaletteVibrant?: string; + @state() private _colorPaletteMuted?: string; + @state() private _colorPaletteDarkVibrant?: string; + @state() private _colorPaletteDarkMuted?: string; + @state() private _colorPaletteLightVibrant?: string; + @state() private _colorPaletteLightMuted?: string; + + /** MediaPlayer instance created from the configuration entity id. */ + private player!: MediaPlayer; + + + /** + * 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 { + + // set common references from application common storage area. + this.config = this.store.config; + this.player = this.store.player; + + // render html. + return html` +
+ +
+
+ ${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""} +
+ ${(() => { + // if favorites disabled then we don't need to display anything in the body. + if ((this.config.playerControlsHideFavorites || false) == true) { + return (html``); + } else if (this.player.attributes.media_content_type == 'music') { + return (html``); + } else if (this.player.attributes.sp_item_type == 'podcast') { + return (html``); + } else if (this.player.attributes.sp_item_type == 'audiobook') { + return (html``); + } else { + return (html`
`); + } + })()} +
+ +
+ `; + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + return css` + + .hoverable:focus, + .hoverable:hover { + color: var(--dark-primary-color); + } + + .hoverable:active { + color: var(--primary-color); + } + + .player-section-container { + display: grid; + grid-template-columns: 100%; + grid-template-rows: min-content auto min-content; + grid-template-areas: + 'header' + 'body' + 'controls'; + align-items: center; + /*background-color: #000000;*/ + background-position: center; + background-repeat: no-repeat; + background-size: var(--spc-player-background-size); + text-align: -webkit-center; + height: 100%; + width: 100%; + } + + .player-section-header { + /* border: 1px solid red; /* FOR TESTING CONTROL LAYOUT CHANGES */ + grid-area: header; + background: linear-gradient(180deg, var(--spc-player-header-bg-color) 30%, transparent 100%); + background-repeat: no-repeat; + } + + .player-section-body { + /* border: 1px solid orange; /* FOR TESTING CONTROL LAYOUT CHANGES */ + grid-area: body; + height: 100%; + overflow: hidden; + padding: 0.5rem; + box-sizing: border-box; + background: transparent; + } + + .player-section-body-content { + /* border: 1px solid yellow; /* FOR TESTING CONTROL LAYOUT CHANGES */ + height: inherit; + background: transparent; + overflow: hidden; + display: none; /* don't display initially */ + + /* for fade-in, fade-out support */ + transition: opacity 0.25s, display 0.25s; + transition-behavior: allow-discrete; /* Note: be sure to write this after the shorthand */ + } + + .player-section-controls { + /* border: 1px solid blue; /* FOR TESTING CONTROL LAYOUT CHANGES */ + grid-area: controls; + overflow-y: auto; + background: linear-gradient(0deg, var(--spc-player-controls-bg-color) 30%, transparent 100%); + background-repeat: no-repeat; + } + + /* have to set a background color for alerts due to parent background transparency. */ + .player-alert-bgcolor { + background-color: rgba(var(--rgb-card-background-color), 0.92); + } + + `; + } + + + /** + * Returns a background image style. + */ + 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. + backgroundSize = 'contain'; + } + + // get various image source settings. + const configImagePlayerBg = this.config.customImageUrls?.['playerBackground']; + const configImagePlayerOffBg = this.config.customImageUrls?.['playerOffBackground']; + const configImageDefault = this.config.customImageUrls?.['default']; + + // set header and controls section gradient background. + let headerBackgroundColor = this.config.playerHeaderBackgroundColor || PLAYER_CONTROLS_BACKGROUND_COLOR_DEFAULT; + let controlsBackgroundColor = this.config.playerControlsBackgroundColor || PLAYER_CONTROLS_BACKGROUND_COLOR_DEFAULT; + + // if player is off or unknown, then reset the playerImage value so that one + // of the default images is selected below. + if (this.player.isPoweredOffOrUnknown()) { + this.playerImage = undefined; + this.store.card.footerBackgroundColor = undefined; + } + + // set background image to display (first condition that is true): + // - if customImageUrls `playerOffBackground` is configured AND player is off, then use it. + // - if customImageUrls `playerBackground` is configured, then use it (static image). + // - if media player entity_picture present, then use it (changes with each song). + // - use static logo image (if none of the above). + let imageUrl: string = ''; + if (configImagePlayerOffBg && this.player.isPoweredOffOrUnknown()) { + imageUrl = configImagePlayerOffBg; + } else if (configImagePlayerBg) { + imageUrl = configImagePlayerBg; + } else if (this.playerImage) { + imageUrl = this.playerImage; + } else { + imageUrl = configImageDefault || BRAND_LOGO_IMAGE_BASE64; + headerBackgroundColor = 'transparent'; + controlsBackgroundColor = 'transparent'; + backgroundSize = BRAND_LOGO_IMAGE_SIZE; + } + + 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-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}`, + }); + } + + + /** + * Returns an element style for the header portion of the control. + */ + private styleHeader(): string | undefined { + + // show / hide the header. + const hideHeader = this.config.playerHeaderHide || false; + if (hideHeader) + return `display: none`; + + return + } + + + /** + * Returns an element style for the header portion of the control. + */ + private styleControls(): string | undefined { + + // show / hide the media controls. + const hideControls = this.config.playerControlsHide || false; + if (hideControls) + return `display: none`; + + return + } + + + /** + * Clears the error alert text. + */ + public alertErrorClear(): void { + this.alertError = undefined; + } + + + /** + * Clears the informational alert text. + */ + public alertInfoClear(): void { + this.alertInfo = undefined; + } + + + /** + * Sets the alert info message. + * + * @param message alert message text. + */ + public alertInfoSet(message: string): void { + this.alertInfo = message; + } + + + /** + * Sets the alert error message. + * + * @param message alert message text. + */ + public alertErrorSet(message: string): void { + this.alertError = message; + } + + + /** + * Invoked before `update()` to compute values needed during the update. + * + * We will check for changes in the media player background image. If a + * change is being made, then we will analyze the new image for the vibrant + * color palette. We will then set some css variables with those values for + * use by the different player sections (header, progress, volume, etc). + */ + protected willUpdate(changedProperties: PropertyValues): void { + + // invoke base class method. + super.willUpdate(changedProperties); + + // get list of changed property keys. + const changedPropKeys = Array.from(changedProperties.keys()) + + //if (debuglog.enabled) { + // debuglog("willUpdate - changed property keys:\n", + // JSON.stringify(changedPropKeys), + // ); + //} + + // we only care about "store" property changes at this time, as it contains a + // reference to the "hass" property. we are looking for background image changes. + if (!changedPropKeys.includes('store')) { + return; + } + + let oldImage: string | undefined = undefined; + let newImage: string | undefined = undefined; + let oldMediaContentId: string | undefined = undefined; + let newMediaContentId: string | undefined = undefined; + + // get the old property reference. + const oldStore = changedProperties.get('store') as Store; + if (oldStore) { + + // if a media player was assigned to the store, then get the player image. + // convert the image url from a relative value to a fully-qualified url. + const oldPlayer = oldStore.player; + if (oldPlayer) { + oldImage = (oldPlayer.attributes.entity_picture || oldPlayer.attributes.entity_picture_local); + if (oldImage) { + oldImage = (this.store.hass as HomeAssistantEx).hassUrl(oldImage); + oldMediaContentId = oldPlayer.attributes.media_content_id; + } + } + } + + // check if the player reference is set (in case it was set to undefined). + if (this.store.player) { + + // get the current media player image. + // 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); + newMediaContentId = this.store.player.attributes.media_content_id; + this.playerImage = newImage; + } else { + return; + } + } + + // did the content change? if so, then extract the color differences from the associated image. + // if we are editing the card, then we don't care about vibrant colors. + // note that we cannot compare images here, as it's a cached value and the `cache` portion of + // image url could change even though it's the same content that's playing! + if ((oldMediaContentId != newMediaContentId) && (!isCardInEditPreview(this.store.card))) { + + if (debuglog.enabled) { + debuglog("willUpdate - player content changed:\n- OLD CONTENT ID = %s\n- NEW CONTENT ID = %s\n- OLD IMAGE = %s\n- NEW IMAGE = %s", + JSON.stringify(oldMediaContentId), + JSON.stringify(newMediaContentId), + JSON.stringify(oldImage), + JSON.stringify(newImage), + ); + } + + // extract the color differences from the new image and set the player colors. + this._extractColors(); + + } + } + + + /** + * Extracts color compositions from the background image, which will be used for + * rendering controls that are displayed on top of the background image. + * + * Good resource on the Vibrant package parameters, examples, and other info: + * https://github.com/Vibrant-Colors/node-vibrant + * https://kiko.io/post/Get-and-use-a-dominant-color-that-matches-the-header-image/ + * https://jariz.github.io/vibrant.js/ + * https://github.com/Vibrant-Colors/node-vibrant/issues/44 + */ + private async _extractColors(): Promise { + + //console.log("_extractColors (player) - colors before extract:\n- Vibrant = %s\n- Muted = %s\n- DarkVibrant = %s\n- DarkMuted = %s\n- LightVibrant = %s\n- LightMuted = %s", + // this._colorPaletteVibrant, + // this._colorPaletteMuted, + // this._colorPaletteDarkVibrant, + // this._colorPaletteDarkMuted, + // this._colorPaletteLightVibrant, + // this._colorPaletteLightMuted, + //); + + if (this.playerImage) { + + // set options for vibrant call. + const vibrantOptions = { + "colorCount": 64, // amount of colors in initial palette from which the swatches will be generated. + "quality": 3, // quality. 0 is highest, but takes way more processing. + // "quantizer": 'mmcq', + // "generators": ['default'], + // "filters": ['default'], + } + + // create vibrant instance with our desired options. + const vibrant: Vibrant = new Vibrant(this.playerImage || '', vibrantOptions); + + // get the color palettes for the player background image. + await vibrant.getPalette().then( + (palette: Palette) => { + + //console.log("_extractColors (player) - colors found by getPalette:\n- Vibrant = %s\n- Muted = %s\n- DarkVibrant = %s\n- DarkMuted = %s\n- LightVibrant = %s\n- LightMuted = %s", + // (palette['Vibrant']?.hex) || 'undefined', + // (palette['Muted']?.hex) || 'undefined', + // (palette['DarkVibrant']?.hex) || 'undefined', + // (palette['DarkMuted']?.hex) || 'undefined', + // (palette['LightVibrant']?.hex) || 'undefined', + // (palette['LightMuted']?.hex) || 'undefined', + //); + + // set player color palette values. + this._colorPaletteVibrant = (palette['Vibrant']?.hex) || undefined; + this._colorPaletteMuted = (palette['Muted']?.hex) || undefined; + this._colorPaletteDarkVibrant = (palette['DarkVibrant']?.hex) || undefined; + this._colorPaletteDarkMuted = (palette['DarkMuted']?.hex) || undefined; + this._colorPaletteLightVibrant = (palette['LightVibrant']?.hex) || undefined; + this._colorPaletteLightMuted = (palette['LightMuted']?.hex) || undefined; + + // set card footer background color. + this.store.card.footerBackgroundColor = this._colorPaletteVibrant; + + }, + (_reason: string) => { + + if (debuglog.enabled) { + debuglog("_extractColors - Could not retrieve color palette info for player background image\nreason = %s", + JSON.stringify(_reason), + ); + } + + // reset player color palette values. + this._colorPaletteVibrant = undefined; + this._colorPaletteMuted = undefined; + this._colorPaletteDarkVibrant = undefined; + this._colorPaletteDarkMuted = undefined; + this._colorPaletteLightVibrant = undefined; + this._colorPaletteLightMuted = undefined; + + // set card footer background color. + this.store.card.footerBackgroundColor = this._colorPaletteVibrant; + + } + ); + } + } +} diff --git a/src/sections/playlist-fav-browser.ts b/src/sections/playlist-fav-browser.ts new file mode 100644 index 0000000..7ae06d9 --- /dev/null +++ b/src/sections/playlist-fav-browser.ts @@ -0,0 +1,199 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +// our imports. +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 { IPlaylistSimplified } from '../types/spotifyplus/playlist-simplified'; + + +@customElement("spc-playlist-fav-browser") +export class PlaylistFavBrowser extends FavBrowserBase { + + /** Array of items to display in the media list. */ + protected override mediaList!: Array | undefined; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.PLAYLIST_FAVORITES); + this.filterCriteriaPlaceholder = "filter by playlist 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. + const title = formatTitleInfo(this.config.playlistFavBrowserTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + const subtitle = formatTitleInfo(this.config.playlistFavBrowserSubTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + + // render html. + return html` +
+ ${title ? html`
${title}
` : html``} + ${subtitle ? html`
${subtitle}
` : html``} +
+ ${!(this.isActionsVisible || false) ? html`` : html`${this.btnHideActionsHtml}`} + ${this.filterCriteriaHtml}${this.refreshMediaListHtml} +
+
+ ${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""} + ${(() => { + // if actions are not visbile, then render the media list. + if (!this.isActionsVisible) { + const filterName = (this.filterCriteria || "").toLocaleLowerCase(); + if (this.config.playlistFavBrowserItemsPerRow === 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} + >` + ) + } + // if actions are visbile, then render the actions display. + } else { + return html``; + } + })()} +
+
+ `; + } + + + /** + * 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. + */ + 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>(); + + // create promise - get media list. + const promiseUpdateMediaList = new Promise((resolve, reject) => { + + // set service parameters. + const limitTotal = this.LIMIT_TOTAL_MAX; // max # of items to return + const sortResult = this.config.playlistFavBrowserItemsSortTitle || false; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetPlaylistFavorites(player.id, 0, 0, limitTotal, sortResult) + .then(result => { + + // load media list results. + this.mediaList = result.items; + this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + + // resolve the promise. + 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 Playlist Followed failed: \n" + (error as Error).message); + + // reject the promise. + reject(error); + + }) + }); + + promiseRequests.push(promiseUpdateMediaList); + + // 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("Playlist followed refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} \ No newline at end of file diff --git a/src/sections/recent-browser.ts b/src/sections/recent-browser.ts new file mode 100644 index 0000000..417fd78 --- /dev/null +++ b/src/sections/recent-browser.ts @@ -0,0 +1,199 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +// our imports. +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 { GetTracks } from '../types/spotifyplus/track-page-saved'; + + +@customElement("spc-recent-browser") +export class RecentBrowser extends FavBrowserBase { + + /** Array of items to display in the media list. */ + protected override mediaList!: Array | undefined; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.RECENTS); + this.filterCriteriaPlaceholder = "filter by 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. + const title = formatTitleInfo(this.config.recentBrowserTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + const subtitle = formatTitleInfo(this.config.recentBrowserSubTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + + // render html. + return html` +
+ ${title ? html`
${title}
` : html``} + ${subtitle ? html`
${subtitle}
` : html``} +
+ ${!(this.isActionsVisible || false) ? html`` : html`${this.btnHideActionsHtml}`} + ${this.filterCriteriaHtml}${this.refreshMediaListHtml} +
+
+ ${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""} + ${(() => { + // if actions are not visbile, then render the media list. + if (!this.isActionsVisible) { + const filterName = (this.filterCriteria || "").toLocaleLowerCase(); + if (this.config.recentBrowserItemsPerRow === 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} + >` + ) + } + // if actions are visbile, then render the actions display. + } else { + return html``; + } + })()} +
+
+ `; + } + + + /** + * 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. + */ + 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>(); + + // create promise - get media list. + const promiseUpdateMediaList = new Promise((resolve, reject) => { + + // set service parameters. + const limitTotal = this.LIMIT_TOTAL_MAX; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetPlayerRecentTracks(player.id, 0, 0, 0, limitTotal) + .then(result => { + + // load media list results. + this.mediaList = GetTracks(result); + this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + + // resolve the promise. + 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 Player Recent Tracks failed: \n" + (error as Error).message); + + // reject the promise. + reject(error); + + }) + }); + + promiseRequests.push(promiseUpdateMediaList); + + // 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("Recently Played items refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} \ No newline at end of file diff --git a/src/sections/search-media-browser.ts b/src/sections/search-media-browser.ts new file mode 100644 index 0000000..203c353 --- /dev/null +++ b/src/sections/search-media-browser.ts @@ -0,0 +1,461 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { customElement, query, state } from 'lit/decorators.js'; +import { + mdiAlbum, + mdiBookOpenVariant, + mdiMicrophone, + mdiMenuDown, + mdiMusic, + mdiAccountMusic, + mdiPlaylistPlay, + mdiPodcast, +} from '@mdi/js'; + +// our imports. +import '../components/media-browser-list'; +import '../components/media-browser-icons'; +import '../components/album-actions'; +import '../components/artist-actions'; +import '../components/audiobook-actions'; +import '../components/episode-actions'; +import '../components/playlist-actions'; +import '../components/show-actions'; +import '../components/track-actions'; +import { sharedStylesFavBrowser } from '../styles/shared-styles-fav-browser.js'; +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 { storageService } from '../decorators/storage'; +import { SearchMediaTypes } from '../types/search-media-types'; + +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from '../constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":search-media-browser"); + +/** Keys used to access cached storage items. */ +const CACHE_KEY_SEARCH_MEDIA_TYPE = "_searchmediatype"; + +const SEARCH_FOR_PREFIX = "Search for "; + + +@customElement("spc-search-media-browser") +export class SearchBrowser extends FavBrowserBase { + + // private state properties. + @state() private searchMediaType?: string; + @state() private searchMediaTypeTitle?: string; + + // html form element objects. + @query("#searchMediaType", false) private searchMediaTypeElement!: HTMLElement; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.SEARCH_MEDIA); + this.filterCriteriaPlaceholder = "search by 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. + const title = formatTitleInfo(this.config.searchMediaBrowserTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + const subtitle = formatTitleInfo(this.config.searchMediaBrowserSubTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + + // get items per row based on configuration settings. + // if not using search settings, then use individual media type settings. + const searchType = this.searchMediaType; + let itemsPerRow = this.config.searchMediaBrowserItemsPerRow || 4; + if (!(this.config.searchMediaBrowserUseDisplaySettings || false)) { + if (searchType == SearchMediaTypes.ALBUMS) { + itemsPerRow = this.config.albumFavBrowserItemsPerRow || 4; + } else if (searchType == SearchMediaTypes.ARTISTS) { + itemsPerRow = this.config.artistFavBrowserItemsPerRow || 4; + } else if (searchType == SearchMediaTypes.AUDIOBOOKS) { + itemsPerRow = this.config.audiobookFavBrowserItemsPerRow || 4; + } else if (searchType == SearchMediaTypes.EPISODES) { + itemsPerRow = this.config.episodeFavBrowserItemsPerRow || 4; + } else if (searchType == SearchMediaTypes.PLAYLISTS) { + itemsPerRow = this.config.playlistFavBrowserItemsPerRow || 4; + } else if (searchType == SearchMediaTypes.SHOWS) { + itemsPerRow = this.config.showFavBrowserItemsPerRow || 4; + } else if (searchType == SearchMediaTypes.TRACKS) { + itemsPerRow = this.config.trackFavBrowserItemsPerRow || 4; + } + } + + // define control to render - search media type. + const searchMediaTypeHtml = html` + + + + + + +
${SearchMediaTypes.ALBUMS}
+
+ + +
${SearchMediaTypes.ARTISTS}
+
+ + +
${SearchMediaTypes.AUDIOBOOKS}
+
+ + +
${SearchMediaTypes.EPISODES}
+
+ + +
${SearchMediaTypes.PLAYLISTS}
+
+ + +
${SearchMediaTypes.SHOWS}
+
+ + +
${SearchMediaTypes.TRACKS}
+
+
+ `; + + // set scroll position (if needed). + this.setScrollPosition(); + + // render html. + return html` + +
+ ${title ? html`
${title}
` : html``} + ${subtitle ? html`
${subtitle}
` : html``} +
+ ${!(this.isActionsVisible || false) ? html`` : html`${this.btnHideActionsHtml}`} + ${searchMediaTypeHtml}${this.filterCriteriaHtml}${this.refreshMediaListHtml} +
+
+ ${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""} + ${(() => { + // if actions are not visbile, then render the media list. + if (!this.isActionsVisible) { + if (itemsPerRow === 1) { + return ( + html`` + ) + } else { + return ( + html`` + ) + } + // if actions are visbile, then render the actions display. + } else if (this.searchMediaType == SearchMediaTypes.ALBUMS) { + return (html``); + } else if (this.searchMediaType == SearchMediaTypes.ARTISTS) { + return (html``); + } else if (this.searchMediaType == SearchMediaTypes.AUDIOBOOKS) { + return (html``); + } else if (this.searchMediaType == SearchMediaTypes.EPISODES) { + return (html``); + } else if (this.searchMediaType == SearchMediaTypes.PLAYLISTS) { + return (html``); + } else if (this.searchMediaType == SearchMediaTypes.SHOWS) { + return (html``); + } else if (this.searchMediaType == SearchMediaTypes.TRACKS) { + return (html``); + } else { + return (html``); + } + })()} +
+
+ `; + } + + + /** + * style definitions used by this component. + * */ + static get styles() { + + return [ + sharedStylesFavBrowser, + css` + + /* extra styles not defined in sharedStylesFavBrowser would go here. */ + + .search-media-browser-controls { + margin-top: 0.5rem; + margin-left: 0.5rem; + margin-right: 0.5rem; + margin-bottom: 0rem; + white-space: nowrap; + align-items: left; + --ha-select-height: 2.5rem; /* ha dropdown control height */ + --mdc-menu-item-height: 2.5rem; /* mdc dropdown list item height */ + --mdc-icon-button-size: 2.5rem; /* mdc icon button size */ + --md-menu-item-top-space: 0.5rem; /* top spacing between items */ + --md-menu-item-bottom-space: 0.5rem; /* bottom spacing between items */ + --md-menu-item-one-line-container-height: 2.0rem; /* menu item height */ + display: inline-flex; + flex-direction: row; + justify-content: left; + } + + .search-media-browser-actions { + height: 100%; + } + + /* related styles */ + ha-assist-chip { + --ha-assist-chip-container-shape: 10px; + --ha-assist-chip-container-color: var(--card-background-color); + } + + .selection-bar { + background: rgba(var(--rgb-primary-color), 0.1); + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + box-sizing: border-box; + font-size: 14px; + --ha-assist-chip-container-color: var(--card-background-color); + } + + .selection-controls { + display: flex; + align-items: center; + gap: 8px; + } + + .selection-controls p { + margin-left: 8px; + margin-inline-start: 8px; + margin-inline-end: initial; + } + + .center-vertical { + display: flex; + align-items: center; + gap: 8px; + } + + .relative { + position: relative; + } + ` + ]; + } + + + /** + * Loads values from persistant storage. + */ + protected override storageValuesLoad() { + + // invoke base class method. + super.storageValuesLoad(); + + // load search-related values from the cache. + this.searchMediaType = storageService.getStorageValue(this.cacheKeyBase + this.mediaType + CACHE_KEY_SEARCH_MEDIA_TYPE, SearchMediaTypes.PLAYLISTS); + this.searchMediaTypeTitle = SEARCH_FOR_PREFIX + this.searchMediaType; + + if (debuglog.enabled) { + debuglog("storageValuesLoad - parameters loaded from cache: searchMediaType"); + } + + } + + + /** + * Saves values to persistant storage. + */ + protected override storageValuesSave() { + + // invoke base class method. + super.storageValuesSave(); + + // save search-related values to the cache. + storageService.setStorageValue(this.cacheKeyBase + this.mediaType + CACHE_KEY_SEARCH_MEDIA_TYPE, this.searchMediaType); + + if (debuglog.enabled) { + debuglog("storageValuesSave - parameters saved to cache: searchMediaType"); + } + + } + + + private onSearchMediaTypeChanged(ev) { + + // if value did not change then don't bother. + if (this.searchMediaType == ev.currentTarget.value) { + return; + } + + // store changed value. + this.searchMediaType = ev.currentTarget.value; + this.searchMediaTypeTitle = SEARCH_FOR_PREFIX + this.searchMediaType; + + // clear the media list, as the items no longer match the search media type. + this.mediaList = undefined; + this.mediaListLastUpdatedOn = 0; + this.scrollTopSaved = 0; + + // clear alerts. + this.alertClear(); + + // hide actions container (if visible). + if (this.isActionsVisible) { + this.isActionsVisible = false; + } + + } + + + /** + * Updates the mediaList display. + */ + protected override updateMediaList(player: MediaPlayer): boolean { + + if (debuglog.enabled) { + debuglog("%c updateMediaList - updating medialist", + "color: yellow;", + ); + } + + // 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; + } + + // 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>(); + + // create promise - get media list. + const promiseUpdateMediaList = new Promise((resolve, reject) => { + + // update status. + this.alertInfo = "Searching Spotify " + this.searchMediaType + " catalog for \"" + this.filterCriteria + "\" ..."; + + // set service parameters. + const limitTotal = this.config.searchMediaBrowserSearchLimit || 50; + const market: string | undefined = undefined; // market code. + const includeExternal: string | undefined = undefined; // include_exclude code. + + // 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.items as [any]; + this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + + // 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. + super.updatedMediaListOk(); + + // resolve the promise. + 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: \n" + (error as Error).message); + + // reject the promise. + reject(error); + + }) + }); + + promiseRequests.push(promiseUpdateMediaList); + + // 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("Spotify " + this.searchMediaType + " search failed: \n" + (error as Error).message); + return true; + + } + finally { + } + + } + +} diff --git a/src/sections/show-fav-browser.ts b/src/sections/show-fav-browser.ts new file mode 100644 index 0000000..4f176f3 --- /dev/null +++ b/src/sections/show-fav-browser.ts @@ -0,0 +1,201 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +// our imports. +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 { GetShows } from '../types/spotifyplus/show-page-saved'; + + +@customElement("spc-show-fav-browser") +export class ShowFavBrowser extends FavBrowserBase { + + /** Array of items to display in the media list. */ + protected override mediaList!: Array | undefined; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.SHOW_FAVORITES); + this.filterCriteriaPlaceholder = "filter by show 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. + const title = formatTitleInfo(this.config.showFavBrowserTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + const subtitle = formatTitleInfo(this.config.showFavBrowserSubTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + + // render html. + return html` +
+ ${title ? html`
${title}
` : html``} + ${subtitle ? html`
${subtitle}
` : html``} +
+ ${!(this.isActionsVisible || false) ? html`` : html`${this.btnHideActionsHtml}`} + ${this.filterCriteriaHtml}${this.refreshMediaListHtml} +
+
+ ${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""} + ${(() => { + // if actions are not visbile, then render the media list. + if (!this.isActionsVisible) { + const filterName = (this.filterCriteria || "").toLocaleLowerCase(); + if (this.config.showFavBrowserItemsPerRow === 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} + >` + ) + } + // if actions are visbile, then render the actions display. + } else { + return html``; + } + })()} +
+
+ `; + } + + + /** + * 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. + */ + 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>(); + + // create promise - get media list. + const promiseUpdateMediaList = new Promise((resolve, reject) => { + + // set service parameters. + const limitTotal = this.LIMIT_TOTAL_MAX; // max # of items to return + const sortResult = this.config.showFavBrowserItemsSortTitle || false; + const excludeAudiobooks = true; + + // call the service to retrieve the media list. + this.spotifyPlusService.GetShowFavorites(player.id, 0, 0, limitTotal, sortResult, excludeAudiobooks) + .then(result => { + + // load media list results. + this.mediaList = GetShows(result); + this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + + // resolve the promise. + 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 Show Favorites failed: \n" + (error as Error).message); + + // reject the promise. + reject(error); + + }) + }); + + promiseRequests.push(promiseUpdateMediaList); + + // 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("Show favorites refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} \ No newline at end of file diff --git a/src/sections/track-fav-browser.ts b/src/sections/track-fav-browser.ts new file mode 100644 index 0000000..d41a068 --- /dev/null +++ b/src/sections/track-fav-browser.ts @@ -0,0 +1,201 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +// our imports. +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 { GetTracks } from '../types/spotifyplus/track-page-saved'; + + +@customElement("spc-track-fav-browser") +export class TrackFavBrowser extends FavBrowserBase { + + /** Array of items to display in the media list. */ + protected override mediaList!: Array | undefined; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.TRACK_FAVORITES); + this.filterCriteriaPlaceholder = "filter by track 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. + const title = formatTitleInfo(this.config.trackFavBrowserTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + const subtitle = formatTitleInfo(this.config.trackFavBrowserSubTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + + // render html. + return html` +
+ ${title ? html`
${title}
` : html``} + ${subtitle ? html`
${subtitle}
` : html``} +
+ ${!(this.isActionsVisible || false) ? html`` : html`${this.btnHideActionsHtml}`} + ${this.filterCriteriaHtml}${this.refreshMediaListHtml} +
+
+ ${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""} + ${(() => { + // if actions are not visbile, then render the media list. + if (!this.isActionsVisible) { + const filterName = (this.filterCriteria || "").toLocaleLowerCase(); + if (this.config.trackFavBrowserItemsPerRow === 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} + >` + ) + } + // if actions are visbile, then render the actions display. + } else { + return html``; + } + })()} +
+
+ `; + } + + + /** + * 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. + */ + 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>(); + + // create promise - get media list. + const promiseUpdateMediaList = new Promise((resolve, reject) => { + + // 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. + + // call the service to retrieve the media list. + this.spotifyPlusService.GetTrackFavorites(player.id, 0, 0, market, limitTotal, sortResult) + .then(result => { + + // load media list results. + this.mediaList = GetTracks(result); + this.mediaListLastUpdatedOn = result.lastUpdatedOn || (Date.now() / 1000); + + // call base class method, indicating media list update succeeded. + super.updatedMediaListOk(); + + // resolve the promise. + 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 Track Favorites failed: \n" + (error as Error).message); + + // reject the promise. + reject(error); + + }) + }); + + promiseRequests.push(promiseUpdateMediaList); + + // 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("Track favorites refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} \ No newline at end of file diff --git a/src/sections/userpreset-browser.ts b/src/sections/userpreset-browser.ts new file mode 100644 index 0000000..d274f38 --- /dev/null +++ b/src/sections/userpreset-browser.ts @@ -0,0 +1,238 @@ +// lovelace card imports. +import { css, html, TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +// our imports. +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 { IUserPreset } from '../types/spotifyplus/user-preset'; + + +@customElement("spc-userpreset-browser") +export class UserPresetBrowser extends FavBrowserBase { + + /** Array of items to display in the media list. */ + protected override mediaList!: Array | undefined; + + + /** + * Initializes a new instance of the class. + */ + constructor() { + + // invoke base class method. + super(Section.USERPRESETS); + this.filterCriteriaPlaceholder = "filter by preset 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. + const title = formatTitleInfo(this.config.userPresetBrowserTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + const subtitle = formatTitleInfo(this.config.userPresetBrowserSubTitle, this.config, this.player, this.mediaListLastUpdatedOn, this.mediaList); + + // render html. + return html` +
+ ${title ? html`
${title}
` : html``} + ${subtitle ? html`
${subtitle}
` : html``} +
+ ${!(this.isActionsVisible || false) ? html`` : html`${this.btnHideActionsHtml}`} + ${this.filterCriteriaHtml}${this.refreshMediaListHtml} +
+
+ ${this.alertError ? html`${this.alertError}` : ""} + ${this.alertInfo ? html`${this.alertInfo}` : ""} + ${(() => { + // if actions are not visbile, then render the media list. + if (!this.isActionsVisible) { + const filterName = (this.filterCriteria || "").toLocaleLowerCase(); + if (this.config.userPresetBrowserItemsPerRow === 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} + >` + ) + } + // if actions are visbile, then render the actions display. + } else { + return html``; + } + })()} +
+
+ `; + } + + + /** + * 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. + */ + 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 { + + // initialize the media list, as we are loading it from multiple sources. + this.mediaListLastUpdatedOn = (Date.now() / 1000); + this.mediaList = new Array(); + + // 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>(); + + // create promise - get media list from config settings. + const promiseUpdateMediaListConfig = new Promise((resolve, reject) => { + + try { + + // load settings, append to the media list, and resolve the promise. + const result = JSON.parse(JSON.stringify(this.config.userPresets || [])) as IUserPreset[]; + if (result) { + + // set where the configuration items were loaded from. + result.forEach(item => { + item.origin = "card config"; + }); + + // append results to media list. + (this.mediaList || []).push(...result); + } + resolve(true); + } + catch (error) { + + // reject the promise. + super.updatedMediaListError("Load User Presets from config failed: \n" + (error as Error).message); + reject(error); + + } + }); + + promiseRequests.push(promiseUpdateMediaListConfig); + + // was a user presets url specified? + if (this.config.userPresetsFile || '' != '') { + + // create promise - get media list from user presets url. + const promiseUpdateMediaListUrl = new Promise((resolve, reject) => { + + // call fetch api to get media list content from the url. + // note that "nocache=" will force refresh, if url content is cached. + fetch(this.config.userPresetsFile + '?nocache=' + Date.now()) + .then(response => { + // if bad response then raise an exception with error details. + if (!response.ok) { + throw new Error("server response: " + response.status + " " + response.statusText); + } + // otherwise, return json response data. + return response.json(); + }) + .then(response => { + // append to the media list, and resolve the promise. + const responseObj = response as IUserPreset[] + if (responseObj) { + + // set where the configuration items were loaded from. + responseObj.forEach(item => { + item.origin = this.config.userPresetsFile as string; + }); + + // append results to media list. + (this.mediaList || []).push(...responseObj); + } + resolve(true); + }) + .catch(error => { + // process error result and reject the promise. + super.updatedMediaListError("Could not fetch data from configuration `userPresetsFile` (" + this.config.userPresetsFile + "); " + (error as Error).message); + reject(error); + }); + }); + + promiseRequests.push(promiseUpdateMediaListUrl); + } + + // 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("User Presets favorites refresh failed: \n" + (error as Error).message); + return true; + + } + finally { + } + } + +} \ No newline at end of file diff --git a/src/services/hass-service.ts b/src/services/hass-service.ts new file mode 100644 index 0000000..448fa7e --- /dev/null +++ b/src/services/hass-service.ts @@ -0,0 +1,102 @@ +// lovelace card imports. +import { HomeAssistant } from 'custom-card-helpers'; +import { ServiceCallRequest } from 'custom-card-helpers/dist/types'; +import { HassEntity } from 'home-assistant-js-websocket'; + +// our imports. +import { MediaPlayer } from '../model/media-player'; +import { MediaPlayerItem, TemplateResult } from '../types'; + +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from '../constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":hass-service"); + + +export class HassService { + + /** Home Assistant instance. */ + private readonly hass: HomeAssistant; + + + /** + * Initializes a new instance of the class. + * + * @param hass Home Assistant instance. + * @param card Custom card instance. + */ + constructor(hass: HomeAssistant) { + this.hass = hass; + } + + + /** + * Calls the specified MediaPlayer service, passing it the specified parameters. + * + * @param serviceRequest Service request instance that contains the service to call and its parameters. + */ + public async CallService(serviceRequest: ServiceCallRequest): Promise { + + try { + + if (debuglog.enabled) { + debuglog("%c CallService - Calling service %s (no response)\n%s", + "color: orange;", + JSON.stringify(serviceRequest.service), + JSON.stringify(serviceRequest, null, 2), + ); + } + + // call the service. + await this.hass.callService( + serviceRequest.domain, + serviceRequest.service, + serviceRequest.serviceData, + serviceRequest.target, + ) + + } + finally { + } + } + + + async browseMedia(mediaPlayer: MediaPlayer, media_content_type?: string, media_content_id?: string) { + const mediaPlayerItem = await this.hass.callWS({ + type: 'media_player/browse_media', + entity_id: mediaPlayer.id, + media_content_id, + media_content_type, + }); + //if (this.config.imageUrlsReplaceHttpWithHttps) { + // mediaPlayerItem.children = mediaPlayerItem.children?.map((child) => ({ + // ...child, + // thumbnail: child.thumbnail?.replace('http://', 'https://'), + // })); + //} + return mediaPlayerItem; + } + + + async getRelatedEntities(player: MediaPlayer, ...entityTypes: string[]) { + return new Promise(async (resolve, reject) => { + const subscribeMessage = { + type: 'render_template', + template: "{{ device_entities(device_id('" + player.id + "')) }}", + }; + try { + const unsubscribe = await this.hass.connection.subscribeMessage((response) => { + unsubscribe(); + resolve( + response.result + .filter((item: string) => entityTypes.some((type) => item.includes(type))) + .map((item) => this.hass.states[item]), + ); + }, subscribeMessage); + } + catch (e) { + reject(e); + } + }); + } +} diff --git a/src/services/media-control-service.ts b/src/services/media-control-service.ts new file mode 100644 index 0000000..332230d --- /dev/null +++ b/src/services/media-control-service.ts @@ -0,0 +1,538 @@ +// lovelace card imports. +import { ServiceCallRequest } from 'custom-card-helpers/dist/types'; + +// our imports. +import { HassService } from './hass-service'; +import { MediaPlayerItem } from '../types'; +import { MediaPlayer } from '../model/media-player'; +import { DOMAIN_MEDIA_PLAYER } from '../constants'; + +// media player services. +const SERVICE_TURN_ON = "turn_on"; +const SERVICE_TURN_OFF = "turn_off"; +//const SERVICE_VOLUME_UP = "volume_up"; +//const SERVICE_VOLUME_DOWN = "volume_down"; +const SERVICE_VOLUME_MUTE = "volume_mute"; +const SERVICE_VOLUME_SET = "volume_set"; +const SERVICE_MEDIA_PLAY_PAUSE = "media_play_pause"; +const SERVICE_MEDIA_PLAY = "media_play"; +const SERVICE_MEDIA_PAUSE = "media_pause"; +const SERVICE_MEDIA_STOP = "media_stop"; +const SERVICE_MEDIA_NEXT_TRACK = "media_next_track"; +const SERVICE_MEDIA_PREVIOUS_TRACK = "media_previous_track"; +const SERVICE_MEDIA_SEEK = "media_seek"; +const SERVICE_REPEAT_SET = "repeat_set"; +const SERVICE_SHUFFLE_SET = "shuffle_set"; +const SERVICE_CLEAR_PLAYLIST = "clear_playlist"; +const SERVICE_JOIN = "join"; +const SERVICE_PLAY_MEDIA = "play_media"; +const SERVICE_SELECT_SOUND_MODE = "select_sound_mode"; +const SERVICE_SELECT_SOURCE = "select_source"; +const SERVICE_UNJOIN = "unjoin"; + + +export class MediaControlService { + + private hassService: HassService; + + + /** + * Initializes a new instance of the class. + * + * @param hassService HassService object. + */ + constructor(hassService: HassService) { + this.hassService = hassService; + } + + + /** + * Clears the internal media player play list. + * + * @param player MediaPlayer object to control. + */ + public async clear_playlist(player: MediaPlayer) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_CLEAR_PLAYLIST, + serviceData: { + entity_id: player.id, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Add players to a master player zone. + * + * @param master Player entity-id of the master zone. + * @param groupMembers An array of Player entity-id's to add to the master zone. + */ + public async join(master: string, groupMembers: string[]) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_JOIN, + serviceData: { + entity_id: master, + group_members: groupMembers, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Plays the next available track. + * + * @param player MediaPlayer object to control. + */ + public async media_next_track(player: MediaPlayer) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_MEDIA_NEXT_TRACK, + serviceData: { + entity_id: player.id, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Pauses the playing track. + * + * @param player MediaPlayer object to control. + */ + public async media_pause(player: MediaPlayer) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_MEDIA_PAUSE, + serviceData: { + entity_id: player.id, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Resumes play of the paused track. + * + * @param player MediaPlayer object to control. + */ + public async media_play(player: MediaPlayer) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_MEDIA_PLAY, + serviceData: { + entity_id: player.id, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Toggles between media play and pause states. + * + * @param player MediaPlayer object to control. + */ + public async media_play_pause(player: MediaPlayer) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_MEDIA_PLAY_PAUSE, + serviceData: { + entity_id: player.id, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Plays the previously played track. + * + * @param player MediaPlayer object to control. + */ + public async media_previous_track(player: MediaPlayer) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_MEDIA_PREVIOUS_TRACK, + serviceData: { + entity_id: player.id, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Seeks to the specified position in a playing track. + * + * @param player MediaPlayer object to control. + * @param position Desired position to seek to. + */ + public async media_seek(player: MediaPlayer, position: number) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_MEDIA_SEEK, + serviceData: { + entity_id: player.id, + seek_position: position, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Stops currently playing track. + * + * @param player MediaPlayer object to control. + */ + public async media_stop(player: MediaPlayer) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_MEDIA_STOP, + serviceData: { + entity_id: player.id, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Plays the specified media content. + * + * @param player MediaPlayer object to control. + * @param item MediaPlayerItem object that contains media information to play. + */ + public async play_media(player: MediaPlayer, item: MediaPlayerItem) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_PLAY_MEDIA, + serviceData: { + entity_id: player.id, + media_content_id: item.media_content_id, + media_content_type: item.media_content_type, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Set repeat mode. + * + * @param player MediaPlayer object to control. + * @param repeat Repeat mode to select. + */ + public async repeat_set(player: MediaPlayer, repeat: RepeatMode) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_REPEAT_SET, + serviceData: { + entity_id: player.id, + repeat: repeat, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Selects a sound mode on the specified media player. + * + * @param player MediaPlayer object to control. + * @param sound_mode Sound Mode to select. + */ + public async select_sound_mode(player: MediaPlayer, sound_mode: string) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_SELECT_SOUND_MODE, + serviceData: { + entity_id: player.id, + sound_mode: sound_mode, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Selects a source on the specified media player. + * + * @param player MediaPlayer object to control. + * @param source Source to select. + */ + public async select_source(player: MediaPlayer, source: string) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_SELECT_SOURCE, + serviceData: { + entity_id: player.id, + source: source, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Set shuffle mode. + * + * @param player MediaPlayer object to control. + * @param shuffle Shuffle mode enabled (true) or disabled (false). + */ + public async shuffle_set(player: MediaPlayer, shuffle: boolean) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_SHUFFLE_SET, + serviceData: { + entity_id: player.id, + shuffle: shuffle, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Turns off the media player. + * + * @param player MediaPlayer object to control. + */ + public async turn_off(player: MediaPlayer) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_TURN_OFF, + serviceData: { + entity_id: player.id, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Turns on the media player. + * + * @param player MediaPlayer object to control. + */ + public async turn_on(player: MediaPlayer) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_TURN_ON, + serviceData: { + entity_id: player.id, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Remove players from a master player zone. + * + * @param playerIds An array of Player entity-id's to remove from the master zone. + */ + public async unJoin(playerIds: string[]) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_UNJOIN, + serviceData: { + entity_id: playerIds, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Mutes / unmutes the player volume. + * + * @param player MediaPlayer object to control. + * @param muteVolume True to mute the volume; otherwise, False to unmute the volume. + */ + public async volume_mute(player: MediaPlayer, muteVolume: boolean) { + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_VOLUME_MUTE, + serviceData: { + entity_id: player.id, + is_volume_muted: muteVolume, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + + + /** + * Toggles the volume mute status of the player; + * if muted, then it will be unmuted; + * if unmuted, then it will be muted; + * + * @param player MediaPlayer object to control. + * @param muteVolume True to mute the volume; otherwise, False to unmute the volume. + */ + public async volume_mute_toggle(player: MediaPlayer) { + + const muteVolume = !player.isMuted(); + await this.volume_mute(player, muteVolume); + } + + + /** + * Sets the player volume. + * + * @param player MediaPlayer object to control. + * @param volumePercent Volume level to set, expressed as a percentage (e.g. 1 - 100). + */ + public async volume_set(player: MediaPlayer, volumePercent: number) { + + const volumeLevel = volumePercent / 100; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_MEDIA_PLAYER, + service: SERVICE_VOLUME_SET, + serviceData: { + entity_id: player.id, + volume_level: volumeLevel, + } + }; + + // call the service. + await this.hassService.CallService(serviceRequest); + } + +} + + +/** + * Repeat mode for media player entities. + */ +export enum RepeatMode { + ALL = "all", + OFF = "off", + ONE = "one" +} + + +/** + * Supported features of the media player entity. + */ +export enum MediaPlayerEntityFeature { + PAUSE = 1, + SEEK = 2, + VOLUME_SET = 4, + VOLUME_MUTE = 8, + PREVIOUS_TRACK = 16, + NEXT_TRACK = 32, + + TURN_ON = 128, + TURN_OFF = 256, + PLAY_MEDIA = 512, + VOLUME_BUTTONS = 1024, + SELECT_SOURCE = 2048, + STOP = 4096, + CLEAR_PLAYLIST = 8192, + PLAY = 16384, + SHUFFLE_SET = 32768, + SELECT_SOUND_MODE = 65536, + BROWSE_MEDIA = 131072, + REPEAT_SET = 262144, + GROUPING = 524288, + + // added the following for SpotifyPlus custom functions. + ACTION_FAVES = 999999990, +} + + +/** + * State of media player entities. + */ +export enum MediaPlayerState { + OFF = "off", + ON = "on", + IDLE = "idle", + PLAYING = "playing", + PAUSED = "paused", + STANDBY = "standby", + BUFFERING = "buffering", + UNKNOWN = "unknown", +} \ No newline at end of file diff --git a/src/services/spotifyplus-service.ts b/src/services/spotifyplus-service.ts new file mode 100644 index 0000000..dcc9358 --- /dev/null +++ b/src/services/spotifyplus-service.ts @@ -0,0 +1,3398 @@ +// lovelace card imports. +import { HomeAssistant } from 'custom-card-helpers'; +import { ServiceCallRequest } from 'custom-card-helpers/dist/types'; +import { + mdiGoogleChrome, + mdiMicrosoftEdge, + mdiSpeaker, + mdiWeb, +} from '@mdi/js'; + +// 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'; +import { IAlbum } from '../types/spotifyplus/album'; +import { IAlbumPageSaved } from '../types/spotifyplus/album-page-saved'; +import { IAlbumPageSimplified } from '../types/spotifyplus/album-page-simplified'; +import { IAlbumSimplified } from '../types/spotifyplus/album-simplified'; +import { IArtist } from '../types/spotifyplus/artist'; +import { IArtistInfo } from '../types/spotifyplus/artist-info'; +import { IArtistPage } from '../types/spotifyplus/artist-page'; +import { IAudiobookPageSimplified } from '../types/spotifyplus/audiobook-page-simplified'; +import { IAudiobookSimplified } from '../types/spotifyplus/audiobook-simplified'; +import { IChapter } from '../types/spotifyplus/chapter'; +import { IChapterPageSimplified } from '../types/spotifyplus/chapter-page-simplified'; +import { IEpisode } from '../types/spotifyplus/episode'; +import { IEpisodePageSaved } from '../types/spotifyplus/episode-page-saved'; +import { IEpisodePageSimplified } from '../types/spotifyplus/episode-page-simplified'; +import { IEpisodeSimplified } from '../types/spotifyplus/episode-simplified'; +import { IPlaylistPage } from '../types/spotifyplus/playlist-page'; +import { IPlaylistPageSimplified } from '../types/spotifyplus/playlist-page-simplified'; +import { IPlaylistSimplified } from '../types/spotifyplus/playlist-simplified'; +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 { ISpotifyConnectDevices } from '../types/spotifyplus/spotify-connect-devices'; +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'; + +// debug logging. +import Debug from 'debug/src/browser.js'; +import { DEBUG_APP_NAME } from '../constants'; +const debuglog = Debug(DEBUG_APP_NAME + ":spotifyplus-service"); + + +/** SpotifyPlus custom services provider class. */ +export class SpotifyPlusService { + + /** Home Assistant instance. */ + private readonly hass: HomeAssistant; + + /** Custom card instance. */ + public readonly card: Element; + + + /** + * Initializes a new instance of the class. + * + * @param hass HomeAssistant instance. + * @param card Parent custom card instance. + * @param section Currently selected section of the card. + */ + constructor(hass: HomeAssistant, card: Element) { + + // initialize storage. + this.hass = hass; + this.card = card; + } + + + /** + * Calls the specified SpotifyPlus service, passing it the specified parameters. + * + * @param serviceRequest Service request instance that contains the service to call and its parameters. + */ + public async CallService( + serviceRequest: ServiceCallRequest, + ): Promise { + + try { + + if (debuglog.enabled) { + debuglog("%cCallService - Calling service %s (no response)\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(serviceRequest, null, 2) + ); + } + + // call the service. + await this.hass.callService( + serviceRequest.domain, + serviceRequest.service, + serviceRequest.serviceData, + serviceRequest.target, + ) + + } + finally { + } + } + + + /** + * Calls the specified SpotifyPlus service and returns response data that is generated by the + * service. The service is called via a script, as there is currently no way to return service + * response data from a call to "hass.callService()" (as of 2024/04/26). + * + * @param serviceRequest Service request instance that contains the service to call and its parameters. + * @returns Response data, in the form of a Record (e.g. dictionary). + */ + public async CallServiceWithResponse( + serviceRequest: ServiceCallRequest, + ): Promise { + + try { + + if (debuglog.enabled) { + debuglog("%cCallServiceWithResponse - Calling service %s (with response)\n%s", + "color: orange", + JSON.stringify(serviceRequest.service), + JSON.stringify(serviceRequest, null, 2) + ); + } + + // 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" + }] + }); + + //console.log("CallServiceWithResponse (spotifyplus-service) - Service Response:\n%s", + // JSON.stringify(serviceResponse.response) + //); + + // return the service response data or an empty dictionary if no response data was generated. + return JSON.stringify(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; + //} + + + /** + * Check if one or more albums (or the currently playing album) exists in the current + * user's 'Your Library' favorites. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the albums. If null, the currently playing track album uri id value is used. + * @returns Response data, in the form of a Record (e.g. dictionary). + */ + public async CheckAlbumFavorites( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'check_album_favorites', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult); + return responseObj; + + } + finally { + } + } + + + /** + * Check if one or more artists (or the currently playing artist) exists in the current + * user's 'Your Library' favorites. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the artists. If null, the currently playing track artist uri id value is used. + * @returns Response data, in the form of a Record (e.g. dictionary). + */ + public async CheckArtistsFollowing( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'check_artists_following', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult); + return responseObj; + + } + finally { + } + } + + + /** + * Check if one or more audiobooks (or the currently playing audiobook) exists in the current + * user's 'Your Library' favorites. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the audiobooks. If null, the currently playing audiobook uri id value is used. + * @returns Response data, in the form of a Record (e.g. dictionary). + */ + public async CheckAudiobookFavorites( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'check_audiobook_favorites', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult); + return responseObj; + + } + finally { + } + } + + + /** + * Check if one or more episodes (or the currently playing episode) exists in the current + * user's 'Your Library' favorites. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the episodes. If null, the currently playing episode uri id value is used. + * @returns Response data, in the form of a Record (e.g. dictionary). + */ + public async CheckEpisodeFavorites( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'check_episode_favorites', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult); + return responseObj; + + } + finally { + } + } + + + /** + * Check to see if the current user is following a specified playlist. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param playlist_id The Spotify ID of the playlist (e.g. `3cEYpjA9oz9GiPac4AsH4n`). + * @returns Response data, in the form of a Record (e.g. dictionary). + */ + public async CheckPlaylistFollowers( + entity_id: string, + playlist_id: string, + user_ids: string | undefined | null = null, + ): Promise> { + + try { + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + playlist_id: playlist_id, + }; + + // update service data parameters (with optional parameters). + if (user_ids) + serviceData['user_ids'] = user_ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'check_playlist_followers', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult); + return responseObj; + + } + finally { + } + } + + + /** + * Check if one or more shows (or the currently playing show) exists in the current + * user's 'Your Library' favorites. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the shows. If null, the currently playing show uri id value is used. + * @returns Response data, in the form of a Record (e.g. dictionary). + */ + public async CheckShowFavorites( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'check_show_favorites', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult); + return responseObj; + + } + finally { + } + } + + + /** + * Check if one or more tracks (or the currently playing track) exists in the current + * user's 'Your Library' favorites. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the tracks. If null, the currently playing track uri id value is used. + * @returns Response data, in the form of a Record (e.g. dictionary). + */ + public async CheckTrackFavorites( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'check_track_favorites', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult); + return responseObj; + + } + finally { + } + } + + + /** + * Add the current user as a follower of one or more artists. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the artists. If null, the currently playing track artist uri id value is used. + */ + public async FollowArtists( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'follow_artists', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + + /** + * Add the current user as a follower of a playlist. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param playlist_id The Spotify ID of the playlist (e.g. `3cEYpjA9oz9GiPac4AsH4n`). If null, the currently playing playlist uri id value is used. + * @param public If true the playlist will be included in user's public playlists, if false it will remain private. Default is True. + */ + public async FollowPlaylist( + entity_id: string, + playlist_id: string | undefined | null = null, + is_public: boolean | undefined | null = 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 (playlist_id) + serviceData['playlist_id'] = playlist_id; + if (is_public) + serviceData['public'] = is_public; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'follow_playlist', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + + /** + * Get Spotify catalog information for a single album identified by its unique Spotify ID. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param album_id The Spotify ID of the album. If null, the currently playing album uri id value is used. Example `1kWUud3vY5ij5r62zxpTRy`. + * @param market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param trimResults True to trim certain fields of the output results that are not required and to conserve memory; otherwise, False to return all fields that were returned in by the Spotify Web API. + * @returns A `IAlbum` object that contains the album details. + */ + public async GetAlbum( + entity_id: string, + album_id: string | 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 (album_id) + serviceData['album_id'] = album_id; + if (market) + serviceData['market'] = market; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_album', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IAlbum; + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.tracks != null)) { + responseObj.available_markets = []; + responseObj.images = []; + responseObj.tracks.items.forEach(item => { + item.available_markets = []; + }) + } + } + + return responseObj; + + } + finally { + } + } + + + /** + * Get a list of the albums saved in the current Spotify user's 'Your Library'. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param 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 A AlbumPageSaved object. + */ + public async GetAlbumFavorites( + entity_id: string, + limit: number | null = null, + offset: number | null = null, + market: 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, + }; + + // update service data parameters (with optional parameters). + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + if (market) + serviceData['market'] = market; + 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_album_favorites', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as 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 + + // 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.album.images = []; + item.album.available_markets = []; + if (item.album.tracks) { + item.album.tracks = JSON.parse("{ }") as ITrackPageSimplified; + } + }) + } + } + + // 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 + + return responseObj; + + } + finally { + } + } + + + /** + * Get Spotify catalog information about an album's tracks. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param album_id The Spotify ID of the album (e.g. `6vc9OTcyd3hyzabCmsdnwE`). If null, the currently playing album uri id value is used; a Spotify Free or Premium account is required to correctly read the currently playing context. + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param 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 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 TrackPageSimplified object. + */ + public async GetAlbumTracks( + entity_id: string, + album_id: string | null = null, + limit: number | null = null, + offset: number | null = null, + market: string | undefined | null = null, + limit_total: number | 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 (album_id) + serviceData['album_id'] = album_id; + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + if (market) + serviceData['market'] = market; + if (limit_total) + serviceData['limit_total'] = limit_total; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_album_tracks', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as ITrackPageSimplified; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + responseObj.items.forEach(item => { + item.available_markets = []; + }) + } + } + + return responseObj; + + } + finally { + } + } + + + /** + * Get Spotify catalog information about an artist's albums. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param artist_id The Spotify ID of the artist. If omitted, the currently playing artist uri id value is used. + * @param include_groups A comma-separated list of keywords that will be used to filter the response. If not supplied, only `album` types will be returned. Valid values are `album`, `single`, `appears_on`, `compilation`. + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param 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 A AlbumPageSimplified object. + */ + public async GetArtistAlbums( + entity_id: string, + artist_id: string | undefined | null = null, + include_groups: string | undefined | null = null, + limit: number | null = null, + offset: number | null = null, + market: 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, + }; + + // update service data parameters (with optional parameters). + if (artist_id) + serviceData['artist_id'] = artist_id; + if (include_groups) + serviceData['include_groups'] = include_groups; + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + if (market) + serviceData['market'] = market; + 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_artist_albums', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IAlbumPageSimplified; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + responseObj.items.forEach(item => { + item.images = []; + item.available_markets = [] + }) + } + } + + // 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 + + return responseObj; + + } + finally { + } + } + + + /** + * Get artist about information from the Spotify Artist Biography page for the specified Spotify artist ID. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param artist_id The Spotify ID of the artist. If omitted, the currently playing artist uri id value is used. + * @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 IArtistInfo object. + */ + public async GetArtistInfo( + entity_id: string, + artist_id: 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 (artist_id) + serviceData['artist_id'] = artist_id; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_artist_info', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IArtistInfo; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if (responseObj != null) { + // nothing to trim at this point. + } + } + + return responseObj; + + } + finally { + } + } + + + /** + * Get the current user's followed artists. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param after The last artist ID retrieved from the previous request, or null for the first request. Example: `6APm8EjxOHSYM5B4i3vT3q`. + * @param limit The maximum number of items to return in a page of items when manual paging is used. Default: 20, Range: 1 to 50. See the `limit_total` argument for automatic paging option. + * @param limit_total The maximum number of items to return for the request. 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 number 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 A ArtistPage object. + */ + public async GetArtistsFollowed( + entity_id: string, + after: number | null = null, + limit: number | 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, + }; + + // update service data parameters (with optional parameters). + if (after) + serviceData['after'] = after; + if (limit) + serviceData['limit'] = limit; + 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_artists_followed', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IArtistPage; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + responseObj.items.forEach(item => { + item.images = []; + }) + } + } + + // 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 + + return responseObj; + + } + finally { + } + } + + + /** + * Get Spotify catalog information about an audiobook's chapters. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param audiobook_id The Spotify ID for the audiobook (e.g. `74aydHJKgYz3AIq3jjBSv1`). If null, the currently playing audiobook uri id value is used. + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param 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 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 ChapterPageSimplified object. + */ + public async GetAudiobookChapters( + entity_id: string, + audiobook_id: string | null = null, + limit: number | null = null, + offset: number | null = null, + market: string | null = null, + limit_total: number | null = null, + trimResults: boolean = true, + ): Promise { + + try { + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + }; + + // update service data parameters (with optional parameters). + if (audiobook_id) + serviceData['audiobook_id'] = audiobook_id; + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + if (market) + serviceData['market'] = market; + if (limit_total) + serviceData['limit_total'] = limit_total; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_audiobook_chapters', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IChapterPageSimplified; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + responseObj.items.forEach(item => { + item.available_markets = []; + item.description = 'see html_description'; + item.images = []; + }) + } + } + + // 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 + + return responseObj; + + } + finally { + } + } + + + /** + * Get a list of the audiobooks owned or followed by the current Spotify user. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 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 A AudiobookPageSimplified object. + */ + public async GetAudiobookFavorites( + entity_id: string, + limit: number | null = null, + offset: number | 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, + }; + + // update service data parameters (with optional parameters). + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + 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_audiobook_favorites', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IAudiobookPageSimplified; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + responseObj.items.forEach(item => { + item.available_markets = []; + item.description = 'see html_description'; + item.images = []; + }) + } + } + + // 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 + + return responseObj; + + } + finally { + } + } + + + /** + * Get Spotify catalog information for a single audiobook chapter identified by its unique Spotify ID. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param chapter_id The Spotify ID of the chapter. If null, the currently playing episode uri id value is used. Example `3V0yw9UDrYAfkhAvTrvt9Y`. + * @param market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param trimResults True to trim certain fields of the output results that are not required and to conserve memory; otherwise, False to return all fields that were returned in by the Spotify Web API. + * @returns A `IChapter` object that contains the chapter details. + */ + public async GetChapter( + entity_id: string, + chapter_id: string | 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 (chapter_id) + serviceData['chapter_id'] = chapter_id; + if (market) + serviceData['market'] = market; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_chapter', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IChapter; + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if (responseObj != null) { + responseObj.available_markets = []; + responseObj.description = 'see html_description'; + responseObj.images = []; + if (responseObj.audiobook) { + responseObj.audiobook.available_markets = [] + responseObj.audiobook.images = [] + responseObj.audiobook.description = 'see html_description'; + } + } + } + + return responseObj; + + } + finally { + } + } + + + /** + * Get Spotify catalog information for a single episode identified by its unique Spotify ID. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param episode_id The Spotify ID of the episode. If null, the currently playing episode uri id value is used. Example `1kWUud3vY5ij5r62zxpTRy`. + * @param market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param trimResults True to trim certain fields of the output results that are not required and to conserve memory; otherwise, False to return all fields that were returned in by the Spotify Web API. + * @returns A `IEpisode` object that contains the episode details. + */ + public async GetEpisode( + entity_id: string, + episode_id: string | 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 (episode_id) + serviceData['episode_id'] = episode_id; + if (market) + serviceData['market'] = market; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_episode', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IEpisode; + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if (responseObj != null) { + responseObj.description = 'see html_description'; + responseObj.images = []; + if (responseObj.show) { + responseObj.show.available_markets = [] + responseObj.show.images = [] + responseObj.show.description = 'see html_description'; + } + } + } + + return responseObj; + + } + finally { + } + } + + + /** + * Get a list of the episodes saved in the current Spotify user's 'Your Library'. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 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 exclude_audiobooks True to exclude audiobook shows from the returned list, leaving only podcast shows; otherwise, False to include all results returned by the Spotify Web API. Default: 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 IEpisodePageSaved object. + */ + public async GetEpisodeFavorites( + entity_id: string, + limit: number | null = null, + offset: number | 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, + }; + + // update service data parameters (with optional parameters). + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + 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_episode_favorites', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IEpisodePageSaved; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + responseObj.items.forEach(item => { + item.episode.description = 'see html_description'; + item.episode.images = []; + item.episode.show.available_markets = []; + item.episode.show.description = 'see html_description'; + item.episode.show.images = []; + }) + } + } + + // 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 + + return responseObj; + + } + finally { + } + } + + + /** + * Get tracks from the current user's recently played tracks. + * Note: Currently doesn't support podcast episodes. + * + * The `after` and `before` arguments are based upon local time (not UTC time). Recently + * played item history uses a local timestamp, and NOT a UTC timestamp. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @param after Returns all items after (but not including) this cursor position, which is a Unix timestamp in milliseconds. If `after` is specified, `before` must not be specified. Use with limit to get the next set of items. Default: `0` (the first item). + * @param before Returns all items before (but not including) this cursor position, which is a Unix timestamp in milliseconds. If `before` is specified, `after` must not be specified. Use with limit to get the next set of items. Default: `0` (the first item). + * @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 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 PlayHistoryPage object. + */ + public async GetPlayerRecentTracks( + entity_id: string, + limit: number | null = null, + after: number | null = null, + before: number | null = null, + limit_total: number | 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 (after) + serviceData['after'] = after; + if (before) + serviceData['before'] = before; + if (limit_total) + serviceData['limit_total'] = limit_total; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_player_recent_tracks', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IPlayHistoryPage; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + responseObj.items.forEach(item => { + item.track.available_markets = []; + item.track.album.images = []; + item.track.album.available_markets = []; + }) + } + } + + // 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 + + return responseObj; + + } + finally { + } + } + + + /** + * Get a list of the playlists owned or followed by the current Spotify user. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 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 A PlaylistPageSimplified object. + */ + public async GetPlaylistFavorites( + entity_id: string, + limit: number | null = null, + offset: number | 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, + }; + + // update service data parameters (with optional parameters). + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + 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_playlist_favorites', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IPlaylistPageSimplified; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + responseObj.items.forEach(item => { + item.images = []; + }) + } + } + + // 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 + + return responseObj; + + } + finally { + } + } + + + /** + * Get a list of the playlists owned or followed by the current Spotify user. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param playlist_id The Spotify ID of the playlist (e.g. 5v5ETK9WFXAnGQ3MRubKuE). If null, the currently playing playlist uri id value is used. + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param fields Filters for the query; a comma-separated list of the fields to return. If omitted, all fields are returned. For example, specify 'items(track(name,uri))' to get just the playlist's track names and URIs. + * @param additional_types A comma-separated list of item types that your client supports besides the default track type. Valid types are 'track' and 'episode'. + * @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 A PlaylistPageSimplified object. + */ + public async GetPlaylistItems( + entity_id: string, + playlist_id: string | undefined | null = null, + limit: number | null = null, + offset: number | null = null, + market: string | undefined | null = null, + fields: string | undefined | null = null, + additional_types: string | undefined | null = null, + limit_total: number | 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 (playlist_id) + serviceData['playlist_id'] = playlist_id; + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + if (market) + serviceData['market'] = market; + if (fields) + serviceData['fields'] = fields; + if (additional_types) + serviceData['additional_types'] = additional_types; + if (limit_total) + serviceData['limit_total'] = limit_total; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_playlist_items', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IPlaylistPage; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + responseObj.items.forEach(playlistTrack => { + if (playlistTrack.track) { + if (playlistTrack.track.available_markets) { + playlistTrack.track.available_markets = []; + } + if (playlistTrack.track.album) { + playlistTrack.track.album.images = [] + if (playlistTrack.track.album.available_markets) { + playlistTrack.track.album.available_markets = []; + } + } + } + }) + } + } + + // 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 + + return responseObj; + + } + finally { + } + } + + + /** + * Get information about all available Spotify Connect player devices. + * + * @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. + * @returns A SpotifyConnectDevices object. + */ + public async GetSpotifyConnectDevices( + entity_id: string, + refresh: boolean | null = null, + sort_result: boolean | null = null, + ): Promise { + + try { + + if (debuglog.enabled) { + debuglog("%c GetSpotifyConnectDevices - retrieving device list from %s", + "color: orange;", + (refresh) ? "real-time query" : "internal device cache", + ); + } + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + }; + + // update service data parameters (with optional parameters). + if (refresh) + serviceData['refresh'] = refresh; + if (sort_result) + serviceData['sort_result'] = sort_result; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_spotify_connect_devices', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as ISpotifyConnectDevices; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // set image_url property based on device type. + if ((responseObj != null) && (responseObj != null)) { + responseObj.Items.forEach(item => { + // set image_url path using mdi icons for common sources. + const sourceCompare = (item.Name || "").toLocaleLowerCase(); + if (sourceCompare.includes('web player (chrome)')) { + item.image_url = getMdiIconImageUrl(mdiGoogleChrome); + } else if (sourceCompare.includes('web player (microsoft edge)')) { + item.image_url = getMdiIconImageUrl(mdiMicrosoftEdge); + } else if (sourceCompare.includes('web player')) { + item.image_url = getMdiIconImageUrl(mdiWeb); + } else { + item.image_url = getMdiIconImageUrl(mdiSpeaker); + } + }) + } + + return responseObj; + + } + finally { + } + } + + + /** + * Get Spotify catalog information about a show's episodes. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param show_id The Spotify ID for the show (e.g. `6kAsbP8pxwaU2kPibKTuHE`). If null, the currently playing show uri id value is used. + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param 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 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 IEpisodePageSimplified object. + */ + public async GetShowEpisodes( + entity_id: string, + show_id: string | null = null, + limit: number | null = null, + offset: number | null = null, + market: string | null = null, + limit_total: number | 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 (show_id) + serviceData['show_id'] = show_id; + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + if (market) + serviceData['market'] = market; + if (limit_total) + serviceData['limit_total'] = limit_total; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_show_episodes', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IEpisodePageSimplified; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + responseObj.items.forEach(item => { + item.description = 'see html_description'; + item.images = []; + }) + } + } + + // 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 + + return responseObj; + + } + finally { + } + } + + + /** + * Get a list of the shows owned or followed by the current Spotify user. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 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 exclude_audiobooks True to exclude audiobook shows from the returned list, leaving only podcast shows; otherwise, False to include all results returned by the Spotify Web API. Default: True + * @param trimResults True to trim certain fields of the output results that are not required and to conserve memory; otherwise, False to return all fields that were returned in by the Spotify Web API. + * @returns A IShowPageSaved object. + */ + public async GetShowFavorites( + entity_id: string, + limit: number | null = null, + offset: number | null = null, + limit_total: number | null = null, + sort_result: boolean | null = null, + exclude_audiobooks: boolean | null = true, + 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 (offset) + serviceData['offset'] = offset; + if (limit_total) + serviceData['limit_total'] = limit_total; + if (sort_result) + serviceData['sort_result'] = sort_result; + if (exclude_audiobooks) + serviceData['exclude_audiobooks'] = exclude_audiobooks; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_show_favorites', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IShowPageSaved; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + responseObj.items.forEach(item => { + item.show.available_markets = []; + item.show.description = 'see html_description'; + item.show.images = []; + }) + } + } + + // 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 + + return responseObj; + + } + finally { + } + } + + + /** + * Get Spotify catalog information for a single track identified by its unique Spotify ID. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param track_id The Spotify ID of the track. If null, the currently playing track uri id value is used. Example `1kWUud3vY5ij5r62zxpTRy`. + * @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 `ITrack` object that contains the track details. + */ + public async GetTrack( + entity_id: string, + track_id: 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 (track_id) + serviceData['track_id'] = track_id; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'get_track', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as ITrack; + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if (responseObj != null) { + responseObj.available_markets = []; + responseObj.album.available_markets = [] + responseObj.album.images = [] + } + } + + return responseObj; + + } + finally { + } + } + + + /** + * Get a list of the tracks saved in the current Spotify user's 'Your Library'. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 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 market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param 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 A TrackPageSaved object. + */ + public async GetTrackFavorites( + entity_id: string, + limit: number | null = null, + offset: number | null = null, + market: 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, + }; + + // update service data parameters (with optional parameters). + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + if (market) + serviceData['market'] = market; + 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_track_favorites', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as ITrackPageSaved; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + responseObj.items.forEach(item => { + item.track.available_markets = []; + item.track.album.available_markets = []; + item.track.album.images = []; + }) + } + } + + // 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 + + return responseObj; + + } + finally { + } + } + + + /** + * Start playing one or more tracks of the specified context on a Spotify Connect device. + * + * @param entity_id + * Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param context_uri + * Spotify URI of the context to play. + * Valid contexts are albums, artists & playlists. + * Example: `spotify:album:6vc9OTcyd3hyzabCmsdnwE`. + * @param offset_uri + * Indicates from what Uri in the context playback should start. + * Only available when context_uri corresponds to an artist, album or playlist. + * The offset_position argument will be used if this value is null. + * For Sonos devices, this argument is ignored. + * Default is null. + * Example: `spotify:track:1301WleyT98MSxVHPZCA6M` start playing at the specified track Uri. + * @param offset_position + * Indicates from what position in the context playback should start. + * The value is zero-based, and must be a positive number, or -1 to disable positioning. + * Only available when context_uri corresponds to an album or playlist. + * Default is `0`. + * Example: `3` start playing at track number 4. + * @param position_ms + * The position in milliseconds to seek to; must be a positive number, or -1 to disable positioning. + * Passing in a position that is greater than the length of the track will cause the + * player to start playing the next track. + * Default is `0`. + * Example: `25000` + * @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: `0d1841b0976bae2a3a310dd74c0f3df354899bc8` + * @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. + */ + public async PlayerMediaPlayContext( + entity_id: string, + context_uri: string, + offset_uri: string | undefined | null = null, + offset_position: number | null = null, + position_ms: number | null = null, + device_id: string | undefined | null = null, + delay: number | null = null, + ): Promise { + + try { + + // validation. + if (!context_uri) + throw new Error("STPC0005 context_uri argument was not supplied to the PlayerMediaPlayContext service.") + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + context_uri: context_uri + }; + + // update service data parameters (with optional parameters). + if (offset_uri) + serviceData['offset_uri'] = offset_uri; + if (offset_position) + serviceData['offset_position'] = offset_position; + if (position_ms) + serviceData['position_ms'] = position_ms; + if (device_id) + serviceData['device_id'] = device_id; + if (delay) + serviceData['delay'] = delay; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'player_media_play_context', + 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. + * + * @param entity_id + * Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param uris + * A comma-delimited string of Spotify URIs to play; can be track or episode URIs. + * Example: `spotify:track:4iV5W9uYEdYUVa79Axb7Rh,spotify:episode:512ojhOuo1ktJprKbVcKyQ`. + * A maximum of 50 items can be added in one request. + * @param position_ms + * The position in milliseconds to seek to; must be a positive number, or -1 to disable positioning. + * Passing in a position that is greater than the length of the track will cause the + * player to start playing the next track. + * Default is `0`. + * Example: `25000` + * @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 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. + */ + public async PlayerMediaPlayTracks( + entity_id: string, + uris: string, + position_ms: number | null = null, + device_id: string | undefined | null = null, + delay: number | null = null, + ): Promise { + + try { + + // validation. + if (!uris) + throw new Error("STPC0005 uris argument was not supplied to the PlayerMediaPlayTracks service.") + if (position_ms == null) + position_ms = 0; + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + uris: uris + }; + + // update service data parameters (with optional parameters). + if (position_ms) + serviceData['position_ms'] = position_ms; + if (device_id) + serviceData['device_id'] = device_id; + if (delay) + serviceData['delay'] = delay; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'player_media_play_tracks', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + + /** + * Remove one or more albums from the current user's 'Your Library'. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the albums. If null, the currently playing track album uri id value is used. + */ + public async RemoveAlbumFavorites( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'remove_album_favorites', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + + /** + * Remove one or more audiobooks from the current user's 'Your Library'. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the audiobooks. If null, the currently playing audiobook uri id value is used. + */ + public async RemoveAudiobookFavorites( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'remove_audiobook_favorites', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + + /** + * Remove one or more episodes from the current user's 'Your Library'. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the episodes. If null, the currently playing episode uri id value is used. + */ + public async RemoveEpisodeFavorites( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'remove_episode_favorites', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + + /** + * Remove one or more shows from the current user's 'Your Library'. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the shows. If null, the currently playing show uri id value is used. + */ + public async RemoveShowFavorites( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'remove_show_favorites', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + + /** + * Remove one or more tracks from the current user's 'Your Library'. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the tracks. If null, the currently playing track uri id value is used. + */ + public async RemoveTrackFavorites( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'remove_track_favorites', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + + /** + * Save one or more albums to the current user's 'Your Library'. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the albums. If null, the currently playing track album uri id value is used. + */ + public async SaveAlbumFavorites( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'save_album_favorites', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + + /** + * Save one or more audiobooks to the current user's 'Your Library'. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the audiobooks. If null, the currently playing audiobook uri id value is used. + */ + public async SaveAudiobookFavorites( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'save_audiobook_favorites', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + + /** + * Save one or more episodes to the current user's 'Your Library'. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the episodes. If null, the currently playing episode uri id value is used. + */ + public async SaveEpisodeFavorites( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'save_episode_favorites', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + + /** + * Save one or more shows to the current user's 'Your Library'. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the shows. If null, the currently playing show uri id value is used. + */ + public async SaveShowFavorites( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'save_show_favorites', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + + /** + * Save one or more tracks to the current user's 'Your Library'. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the tracks. If null, the currently playing track uri id value is used. + */ + public async SaveTrackFavorites( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'save_track_favorites', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + + /** + * Get Spotify catalog information about matching context criteria. + * + * @param context Contexts to search for. + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param criteria Your search query. + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param include_external If "audio" is specified it signals that the client can play externally hosted audio content, and marks the content as playable in the response. By default externally hosted audio content is marked as unplayable in the response. Allowed values: "audio" + * @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) + * @returns A AlbumPageSaved object. + */ + public async Search( + searchMediaType: SearchMediaTypes.ALBUMS | SearchMediaTypes.ARTISTS | SearchMediaTypes.AUDIOBOOKS | SearchMediaTypes.EPISODES | + SearchMediaTypes.PLAYLISTS | SearchMediaTypes.SHOWS | SearchMediaTypes.TRACKS, + entity_id: string, + criteria: string, + limit: number | null = null, + offset: number | null = null, + market: string | undefined | null = null, + include_external: string | undefined | null = null, + limit_total: number | null = null, + ): Promise { + + try { + + // execute based on search media type. + if (searchMediaType == SearchMediaTypes.ALBUMS) { + return await this.SearchAlbums(entity_id, criteria, limit, offset, market, include_external, limit_total) + } else if (searchMediaType == SearchMediaTypes.ARTISTS) { + return await this.SearchArtists(entity_id, criteria, limit, offset, market, include_external, limit_total) + } else if (searchMediaType == SearchMediaTypes.AUDIOBOOKS) { + return await this.SearchAudiobooks(entity_id, criteria, limit, offset, market, include_external, limit_total) + } else if (searchMediaType == SearchMediaTypes.EPISODES) { + return await this.SearchEpisodes(entity_id, criteria, limit, offset, market, include_external, limit_total) + } else if (searchMediaType == SearchMediaTypes.PLAYLISTS) { + return await this.SearchPlaylists(entity_id, criteria, limit, offset, market, include_external, limit_total) + } else if (searchMediaType == SearchMediaTypes.SHOWS) { + return await this.SearchShows(entity_id, criteria, limit, offset, market, include_external, limit_total) + } else if (searchMediaType == SearchMediaTypes.TRACKS) { + return await this.SearchTracks(entity_id, criteria, limit, offset, market, include_external, limit_total) + } else { + throw new Error("searchMediaType was not recognized: \"" + searchMediaType + "\"."); + } + + } + finally { + } + } + + + /** + * Get Spotify catalog information about albums that match a keyword string. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param criteria Your search query. + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param include_external If "audio" is specified it signals that the client can play externally hosted audio content, and marks the content as playable in the response. By default externally hosted audio content is marked as unplayable in the response. Allowed values: "audio" + * @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 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 AlbumPageSaved object. + */ + public async SearchAlbums( + entity_id: string, + criteria: string, + limit: number | null = null, + offset: number | null = null, + market: string | undefined | null = null, + include_external: string | undefined | null = null, + limit_total: number | null = null, + trimResults: boolean = true, + ): Promise { + + try { + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + criteria: criteria, + }; + + // update service data parameters (with optional parameters). + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + if (market) + serviceData['market'] = market; + if (include_external) + serviceData['include_external'] = include_external; + if (limit_total) + serviceData['limit_total'] = limit_total; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'search_albums', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IAlbumPageSimplified; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + (responseObj.items as IAlbumSimplified[]).forEach(item => { + item.images = []; + item.available_markets = []; + }) + } + } + + // 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 + return responseObj; + + } + finally { + } + } + + + /** + * Get Spotify catalog information about artists that match a keyword string. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param criteria Your search query. + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param include_external If "audio" is specified it signals that the client can play externally hosted audio content, and marks the content as playable in the response. By default externally hosted audio content is marked as unplayable in the response. Allowed values: "audio" + * @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 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 IArtistPage object. + */ + public async SearchArtists( + entity_id: string, + criteria: string, + limit: number | null = null, + offset: number | null = null, + market: string | undefined | null = null, + include_external: string | undefined | null = null, + limit_total: number | null = null, + trimResults: boolean = true, + ): Promise { + + try { + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + criteria: criteria, + }; + + // update service data parameters (with optional parameters). + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + if (market) + serviceData['market'] = market; + if (include_external) + serviceData['include_external'] = include_external; + if (limit_total) + serviceData['limit_total'] = limit_total; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'search_artists', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IArtistPage; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + (responseObj.items as IArtist[]).forEach(item => { + item.images = []; + }) + } + } + + // 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 + return responseObj; + + } + finally { + } + } + + + /** + * Get Spotify catalog information about audiobooks that match a keyword string. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param criteria Your search query. + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param include_external If "audio" is specified it signals that the client can play externally hosted audio content, and marks the content as playable in the response. By default externally hosted audio content is marked as unplayable in the response. Allowed values: "audio" + * @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 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 IAudiobookPageSimplified object. + */ + public async SearchAudiobooks( + entity_id: string, + criteria: string, + limit: number | null = null, + offset: number | null = null, + market: string | undefined | null = null, + include_external: string | undefined | null = null, + limit_total: number | null = null, + trimResults: boolean = true, + ): Promise { + + try { + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + criteria: criteria, + }; + + // update service data parameters (with optional parameters). + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + if (market) + serviceData['market'] = market; + if (include_external) + serviceData['include_external'] = include_external; + if (limit_total) + serviceData['limit_total'] = limit_total; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'search_audiobooks', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IAudiobookPageSimplified; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + (responseObj.items as IAudiobookSimplified[]).forEach(item => { + item.images = []; + item.available_markets = []; + item.description = "see html_description"; + }) + } + } + + // 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 + return responseObj; + + } + finally { + } + } + + + /** + * Get Spotify catalog information about episodes that match a keyword string. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param criteria Your search query. + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param include_external If "audio" is specified it signals that the client can play externally hosted audio content, and marks the content as playable in the response. By default externally hosted audio content is marked as unplayable in the response. Allowed values: "audio" + * @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 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 IEpisodePageSimplified object. + */ + public async SearchEpisodes( + entity_id: string, + criteria: string, + limit: number | null = null, + offset: number | null = null, + market: string | undefined | null = null, + include_external: string | undefined | null = null, + limit_total: number | null = null, + trimResults: boolean = true, + ): Promise { + + try { + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + criteria: criteria, + }; + + // update service data parameters (with optional parameters). + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + if (market) + serviceData['market'] = market; + if (include_external) + serviceData['include_external'] = include_external; + if (limit_total) + serviceData['limit_total'] = limit_total; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'search_episodes', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IEpisodePageSimplified; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + (responseObj.items as IEpisodeSimplified[]).forEach(item => { + item.images = []; + item.description = "see html_description"; + }) + } + } + + // 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 + return responseObj; + + } + finally { + } + } + + + /** + * Get Spotify catalog information about playlists that match a keyword string. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param criteria Your search query. + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param include_external If "audio" is specified it signals that the client can play externally hosted audio content, and marks the content as playable in the response. By default externally hosted audio content is marked as unplayable in the response. Allowed values: "audio" + * @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 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 IArtistPage object. + */ + public async SearchPlaylists( + entity_id: string, + criteria: string, + limit: number | null = null, + offset: number | null = null, + market: string | undefined | null = null, + include_external: string | undefined | null = null, + limit_total: number | null = null, + trimResults: boolean = true, + ): Promise { + + try { + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + criteria: criteria, + }; + + // update service data parameters (with optional parameters). + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + if (market) + serviceData['market'] = market; + if (include_external) + serviceData['include_external'] = include_external; + if (limit_total) + serviceData['limit_total'] = limit_total; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'search_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 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); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + (responseObj.items as IPlaylistSimplified[]).forEach(item => { + item.images = []; + }) + } + } + + // 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 + return responseObj; + + } + finally { + } + } + + + /** + * Get Spotify catalog information about shows that match a keyword string. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param criteria Your search query. + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param include_external If "audio" is specified it signals that the client can play externally hosted audio content, and marks the content as playable in the response. By default externally hosted audio content is marked as unplayable in the response. Allowed values: "audio" + * @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 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 IShowPageSimplified object. + */ + public async SearchShows( + entity_id: string, + criteria: string, + limit: number | null = null, + offset: number | null = null, + market: string | undefined | null = null, + include_external: string | undefined | null = null, + limit_total: number | null = null, + trimResults: boolean = true, + ): Promise { + + try { + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + criteria: criteria, + }; + + // update service data parameters (with optional parameters). + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + if (market) + serviceData['market'] = market; + if (include_external) + serviceData['include_external'] = include_external; + if (limit_total) + serviceData['limit_total'] = limit_total; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'search_shows', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as IShowPageSimplified; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + (responseObj.items as IShowSimplified[]).forEach(item => { + item.images = []; + item.available_markets = []; + item.description = "see html_description"; + }) + } + } + + // 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 + return responseObj; + + } + finally { + } + } + + + /** + * Get Spotify catalog information about tracks that match a keyword string. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param criteria Your search query. + * @param limit The maximum number of items to return in a page of items. Default: 20, Range: 1 to 50. + * @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 market An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. Example = 'ES'. + * @param include_external If "audio" is specified it signals that the client can play externally hosted audio content, and marks the content as playable in the response. By default externally hosted audio content is marked as unplayable in the response. Allowed values: "audio" + * @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 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 IArtistPage object. + */ + public async SearchTracks( + entity_id: string, + criteria: string, + limit: number | null = null, + offset: number | null = null, + market: string | undefined | null = null, + include_external: string | undefined | null = null, + limit_total: number | null = null, + trimResults: boolean = true, + ): Promise { + + try { + + // create service data (with required parameters). + const serviceData: { [key: string]: any } = { + entity_id: entity_id, + criteria: criteria, + }; + + // update service data parameters (with optional parameters). + if (limit) + serviceData['limit'] = limit; + if (offset) + serviceData['offset'] = offset; + if (market) + serviceData['market'] = market; + if (include_external) + serviceData['include_external'] = include_external; + if (limit_total) + serviceData['limit_total'] = limit_total; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'search_tracks', + serviceData: serviceData + }; + + // call the service, and return the response. + const response = await this.CallServiceWithResponse(serviceRequest); + + // get the "result" portion of the response, and convert it to a type. + const responseResult = this._GetJsonStringResult(response); + const responseObj = JSON.parse(responseResult) as ITrackPage; + + //// get the "user_profile" portion of the response, and convert it to a type. + //this._GetJsonStringUserProfile(response); + + // omit some data from the results, as it's not necessary and conserves memory. + if (trimResults) { + if ((responseObj != null) && (responseObj.items != null)) { + (responseObj.items as ITrack[]).forEach(item => { + if (item.album) { + item.album.images = []; + item.album.available_markets = []; + } + item.available_markets = []; + }) + } + } + + // 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 + return responseObj; + + } + finally { + } + } + + + /** + * Remove the current user as a follower of one or more artists. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param ids A comma-separated list (50 max) of the Spotify IDs for the artists. If null, the currently playing track artist uri id value is used. + */ + public async UnfollowArtists( + entity_id: string, + ids: string | undefined | 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 (ids) + serviceData['ids'] = ids; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'unfollow_artists', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + + /** + * Remove the current user as a follower of one or more playlists. + * + * @param entity_id Entity ID of the SpotifyPlus device that will process the request (e.g. "media_player.spotifyplus_john_smith"). + * @param playlist_id The Spotify ID of the playlist (e.g. `3cEYpjA9oz9GiPac4AsH4n`). If null, the currently playing playlist uri id value is used. + */ + public async UnfollowPlaylist( + entity_id: string, + playlist_id: string | undefined | 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 (playlist_id) + serviceData['playlist_id'] = playlist_id; + + // create service request. + const serviceRequest: ServiceCallRequest = { + domain: DOMAIN_SPOTIFYPLUS, + service: 'unfollow_playlist', + serviceData: serviceData + }; + + // call the service (no response). + await this.CallService(serviceRequest); + + } + finally { + } + } + + + /** ====================================================================================== + * The following are common helper methods for SpotifyPlus-Card support. + * ====================================================================================== **/ + + /** + * Calls the SpotifyPlusService PlayerMediaPlayX method to play media. + * + * @param player SpotifyPlus media player to direct the request to. + * @param mediaItem Media Browser item that contains media content details to play. + */ + public async Card_PlayMediaBrowserItem( + player: MediaPlayer, + mediaItem: any, + ): Promise { + + // validations. + if (!player) { + throw new Error("Media player argument was not supplied to the PlayMediaBrowserItem service.") + } + if (!mediaItem) { + throw new Error("Media browser item argument was not supplied to the PlayMediaBrowserItem service."); + } + + try { + + // get item type from uri; we cannot use `mediaItem.type` here, as the value + // will not be the same as the uri type for some media items (e.g. chapter). + const uriType = getTypeFromSpotifyUri(mediaItem.uri) || ""; + + if (debuglog.enabled) { + debuglog("Card_PlayMediaBrowserItem - play media item\n- player.id = %s\n- mediaItem.uri = %s\n- uriType = %s", + JSON.stringify(player.id), + JSON.stringify(mediaItem.uri), + JSON.stringify(uriType), + ); + } + + if (['album', 'artist', 'playlist', 'show', 'audiobook', 'podcast'].indexOf(uriType) > -1) { + + // play context. + const device_id = player.attributes.source || null; + await this.PlayerMediaPlayContext(player.id, mediaItem.uri || '', null, null, null, device_id, null); + + } else if (['track', 'episode', 'chapter'].indexOf(uriType) > -1) { + + // play track / episode / chapter. + const device_id = player.attributes.source || null; + await this.PlayerMediaPlayTracks(player.id, mediaItem.uri || '', null, device_id, null); + + } else { + + throw new Error("unknown media type \"" + uriType + "\"."); + } + } + finally { + } + } +} + + +/** +* Gets the Id portion (e.g. "26c0zVyOv1lzfYpBXdh1zC") of a valid Spotify URI +* value (e.g. "spotify:episode:26c0zVyOv1lzfYpBXdh1zC"). +* +* @param uri String value that contains a Spotify URI value (e.g. "spotify:episode:26c0zVyOv1lzfYpBXdh1zC"). +* @returns Id portion of the URI value (e.g. "26c0zVyOv1lzfYpBXdh1zC") if found; otherwise, undefined. +*/ +export function getIdFromSpotifyUri(uri: string | undefined | null): string | undefined | null { + let result = uri; + if (uri) { + const idx = uri.lastIndexOf(':'); + if (idx > -1) { + result = uri.substring(idx + 1) + } + } + return result; +} + + +/** +* Gets the Type portion (e.g. "episode") of a valid Spotify URI +* value (e.g. "spotify:episode:26c0zVyOv1lzfYpBXdh1zC"). +* +* @param uri String value that contains a Spotify URI value (e.g. "spotify:episode:26c0zVyOv1lzfYpBXdh1zC"). +* @returns Type portion of the URI value (e.g. "episode") if found; otherwise, undefined. +*/ +export function getTypeFromSpotifyUri(uri: string | undefined | null): string | undefined | null { + let result = uri; + if (uri) { + const idx = uri.indexOf(':'); + if (idx > -1) { + const idxe = uri.lastIndexOf(':'); + if (idxe > -1) { + result = uri.substring(idx + 1, idxe) + } + } + } + return result; +} diff --git a/src/styles/shared-styles-fav-actions.js b/src/styles/shared-styles-fav-actions.js new file mode 100644 index 0000000..f33bf92 --- /dev/null +++ b/src/styles/shared-styles-fav-actions.js @@ -0,0 +1,90 @@ +import { css } from 'lit'; + +/** + * Shared styles for actions. + * + * See the following link for more information: + * https://codepen.io/neoky/pen/mGpaKN + */ +export const sharedStylesFavActions = css` + + .player-body-container { + box-sizing: border-box; + height: inherit; + background: linear-gradient(rgba(0, 0, 0, 0.65), rgba(0, 0, 0, 0.65)); + border-radius: 1.0rem; + padding: 0.25rem; + text-align: left; + } + + .player-body-container-scrollable { + /* border: 1px solid green; /* FOR TESTING CONTROL LAYOUT CHANGES */ + box-sizing: border-box; + height: inherit; + overflow-y: auto; + color: white; + } + + /* style ha-icon-button controls in header actions: icon size, title text */ + ha-icon-button[slot="icon-button"] { + --mdc-icon-button-size: 30px; + --mdc-icon-size: 24px; + vertical-align: middle; + padding: 2px; + } + + ha-icon-button[slot="icon-button-selected"] { + --mdc-icon-button-size: 30px; + --mdc-icon-size: 24px; + vertical-align: middle; + padding: 2px; + color: red; + } + + /* style ha-icon-button controls in header actions: icon size, title text */ + ha-icon-button[slot="icon-button-small"] { + --mdc-icon-button-size: 20px; + --mdc-icon-size: 20px; + vertical-align: middle; + padding: 2px; + } + + ha-icon-button[slot="icon-button-small-selected"] { + --mdc-icon-button-size: 20px; + --mdc-icon-size: 20px; + vertical-align: middle; + padding: 2px; + color: red; + } + + /* style ha-alert controls */ + ha-alert { + display: block; + margin-bottom: 0.25rem; + } + + .icon-button { + width: 100%; + } + + *[hide] { + display: none; + } + + .flex-1 { + flex: 1; + } + + .flex-items { + display: block; + flex-grow: 0; + flex-shrink: 1; + flex-basis: auto; + align-self: auto; + order: 0; + } + + .display-inline { + display: inline; + } +`; diff --git a/src/styles/shared-styles-fav-browser.js b/src/styles/shared-styles-fav-browser.js new file mode 100644 index 0000000..ecd7ca5 --- /dev/null +++ b/src/styles/shared-styles-fav-browser.js @@ -0,0 +1,91 @@ +import { css } from 'lit'; + +/** + * Shared styles for favorites browsers. + * + * See the following link for more information: + * https://codepen.io/neoky/pen/mGpaKN + */ +export const sharedStylesFavBrowser = css` + + .media-browser-section { + color: var(--secondary-text-color); + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + } + + .media-browser-section-title { + margin-top: 0.5rem; + align-items: center; + display: flex; + flex-shrink: 0; + flex-grow: 0; + justify-content: center; + text-align: center; + font-weight: bold; + font-size: 1.0rem; + color: var(--secondary-text-color); + } + + .media-browser-section-subtitle { + margin: 0.1rem 0; + align-items: center; + display: flex; + justify-content: center; + text-align: center; + font-weight: normal; + font-size: 0.85rem; + color: var(--secondary-text-color); + } + + .media-browser-controls { + margin-top: 0.25rem; + margin-left: 0.5rem; + margin-right: 0.5rem; + margin-bottom: 0rem; + white-space: nowrap; + --ha-select-height: 2.5rem; /* ha dropdown control height */ + --mdc-menu-item-height: 2.5rem; /* mdc dropdown list item height */ + --mdc-icon-button-size: 2.5rem; /* mdc icon button size */ + --md-menu-item-top-space: 0.5rem; /* top spacing between items */ + --md-menu-item-bottom-space: 0.5rem; /* bottom spacing between items */ + --md-menu-item-one-line-container-height: 2.0rem; /* menu item height */ + display: inline-flex; + flex-direction: row; + justify-content: center; + } + + .media-browser-control-filter { + padding-right: 0.5rem; + padding-left: 0.5rem; + width: 100%; + /* min-width: 300px; */ + } + + .media-browser-content { + margin: 0.5rem; + flex: 3; + max-height: 100vh; + overflow-y: auto; + } + + .media-browser-list { + height: 100%; + } + + .media-browser-actions { + height: 100%; + } + + ha-alert { + display: block; + margin-bottom: 0.25rem; + } + + *[hide] { + display: none; + } + +`; diff --git a/src/styles/shared-styles-grid.js b/src/styles/shared-styles-grid.js new file mode 100644 index 0000000..bd8ee93 --- /dev/null +++ b/src/styles/shared-styles-grid.js @@ -0,0 +1,103 @@ +import { css } from 'lit'; + +/** + * Shared styles for grid formatting. + * + * See the following link for more information: + * https://codepen.io/neoky/pen/mGpaKN + */ +export const sharedStylesGrid = css` + + /* define a style in the main object to specify the "grid-template-columns: 80px auto ..." value. */ + .grid { + display: grid; + width: 100%; + } + + /* style grid container */ + .grid-container-scrollable { + overflow-y: auto; + max-height: 100vh; + margin: 0.25rem; + align-self: stretch + } + + .grid-entry, .grid-header { + padding: 2px; + align-self: normal; + /* background-color: white; */ + /* border-right: 1px solid gray; */ + /* border-bottom: 1px solid gray; */ + } + + .grid-entry-last, .grid-header-last { + margin-right: 4px; /* a little padding if scrollbars are present */ + /* border-right: none; */ + } + + .grid-entry-r { + padding: 2px; + justify-self: right; + } + + .grid-entry-c { + padding: 2px; + justify-self: center; + } + + /* scrolling text bleeds through if you set BG-COLOR to transparent! */ + .grid-header { + background-color: var(--card-background-color); + position: sticky; + top: 0; + z-index: 1; + padding: 2px; + border-bottom: 1px solid gray; + border-top: 1px solid gray; + } + + .grid-header-fixed-left { + z-index: 2; + } + + .grid-fixed-left { + position: sticky; + left: 0; + } + + .grid-fixed-right { + /* border-left: 1px solid gray; */ + /* border-right: none; */ + position: sticky; + right: 0; + } + + .grid-fixed-right2 { + /* border-left: 1px solid gray; */ + /* border-right: none; */ + position: sticky; + right: 200px; + } + + .grid-placeholder { + grid-column-start: 1; + grid-column-end: 21; + /* border-right: none; */ + } + + /* styles for action item info grid items. */ + .grid-action-info-hdr-s { + font-size: 0.85rem; + line-height: 1rem; + justify-self: right; + padding-right: 6px; + color: var(--accent-color); + } + + .grid-action-info-text-s { + font-size: 0.85rem; + line-height: 1rem; + justify-self: left; + } + +`; diff --git a/src/styles/shared-styles-media-info.js b/src/styles/shared-styles-media-info.js new file mode 100644 index 0000000..651249e --- /dev/null +++ b/src/styles/shared-styles-media-info.js @@ -0,0 +1,87 @@ +import { css } from 'lit'; + +/** + * Shared styles for media info formatting. + */ +export const sharedStylesMediaInfo = css` + + .media-info-content { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + width: inherit; + gap: 0.25rem; + margin: 0.25rem; + } + + .media-info-content > div { + flex: max(23rem, 100%/3 + 0.1%); /* flexbox is responsive */ + /*border: 1px solid blue;*/ /* FOR TESTING LAYOUT */ + } + + .media-info-content .img { + background-size: contain !important; + background-repeat: no-repeat !important; + background-position: center !important; + max-width: 128px; + min-height: 128px; + border-radius: var(--control-button-border-radius, 10px) !important; + background-size: cover !important; + } + + .media-info-description { + overflow-y: auto; + display: block; + height: inherit; + padding-top: 10px; + } + + .media-info-details { + display: flex; + flex: 1 1 0%; + flex-direction: column; + max-width: 400rem; + margin: 0.5rem; + } + + .media-info-text-l { + font-size: 2.1rem; + font-weight: 400; + line-height: 1.8rem; + padding-bottom: 0.5rem; + width: 100%; + color: var(--dark-primary-color); + } + + .media-info-text-ms, .media-info-text-ms-c { + font-size: 1.2rem; + line-height: 1.5rem; + padding-bottom: 0.20rem; + width: 100%; + } + + .media-info-text-ms-c { + color: var(--dark-primary-color); + } + + .media-info-text-m { + font-size: 1.5rem; + line-height: 1.8rem; + padding-bottom: 0.5rem; + width: 100%; + } + + .media-info-text-s { + font-size: 0.85rem; + line-height: 1rem; + width: 100%; + } + + ha-icon-button[slot="media-info-icon-link-s"] { + --mdc-icon-button-size: 14px; + --mdc-icon-size: 14px; + padding-left: 2px; + padding-right: 2px; + } + +`; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..28a2e0f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,33 @@ +import { HomeAssistant } from 'custom-card-helpers'; + +declare global { + // noinspection JSUnusedGlobalSymbols + interface Window { + customCards: Array<{ type: string; name: string; description: string; preview: boolean }>; + } +} + + +export interface MediaPlayerItem { + title: string; + thumbnail?: string; + children?: MediaPlayerItem[]; + children_media_class?: string; + media_class?: string; + media_content_type?: string; + media_content_id?: string; +} + +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 new file mode 100644 index 0000000..feefccb --- /dev/null +++ b/src/types/card-config.ts @@ -0,0 +1,652 @@ +// lovelace card imports. +import { LovelaceCardConfig } from 'custom-card-helpers'; + +// our imports. +import { Section } from './section'; +import { CustomImageUrls } from './custom-image-urls'; +import { IUserPreset } from './spotifyplus/user-preset'; + +/** + * Card configuration settings. + */ +export interface CardConfig extends LovelaceCardConfig { + + /** + * Entity ID of the SpotifyPlus device that will process the request. + */ + entity: string; + + /** + * Sections of the card to display. + * + * Valid values must match defined names in `secion.ts`. + */ + sections?: Section[]; + + /** + * Title that is displayed at the top of the card, above the section area. + * This value supports Title Formatter Options. + */ + title?: string; + + /** + * Width of the card (in 'rem' units). + * A value of "fill" can also be used (requires manual editing) to use 100% of + * the available horizontal space (good for panel dashboards). + * Default is 35.15rem. + */ + width?: string | number; + + /** + * Height of the card (in 'rem' units). + * A value of "fill" can also be used (requires manual editing) to use 100% of + * the available vertical space (good for panel dashboards). + * Default is 35.15rem. + */ + height?: string | number; + + /** + * Title displayed at the top of the Album Favorites media browser section form. + * Omit this parameter to hide the title display area. + * This value supports Title Formatter Options. + */ + albumFavBrowserTitle?: string; + + /** + * Sub-title displayed at the top of the Album Favorites media browser section form. + * Omit this parameter to hide the sub-title display area. + * This value supports Title Formatter Options. + */ + albumFavBrowserSubTitle?: string; + + /** + * Number of items to display in a single row of the Album Favorites media browser section form. + * Use a value of 1 to display the items as a vertical list. + * Default is 3. + */ + albumFavBrowserItemsPerRow?: number; + + /** + * Hide titles displayed for Album Favorites media browser items. + * Default is false. + */ + albumFavBrowserItemsHideTitle?: boolean; + + /** + * Hide sub-titles displayed for Album Favorites media browser items. + * Default is false. + */ + albumFavBrowserItemsHideSubTitle?: boolean; + + /** + * 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. + * Default is false. + */ + albumFavBrowserItemsSortTitle?: boolean; + + /** + * Title displayed at the top of the Artist Favorites media browser section form. + * Omit this parameter to hide the title display area. + * This value supports Title Formatter Options. + */ + artistFavBrowserTitle?: string; + + /** + * Sub-title displayed at the top of the Artist Favorites media browser section form. + * Omit this parameter to hide the sub-title display area. + * This value supports Title Formatter Options. + */ + artistFavBrowserSubTitle?: string; + + /** + * Number of items to display in a single row of the Artist Favorites media browser section form. + * Use a value of 1 to display the items as a vertical list. + * Default is 3. + */ + artistFavBrowserItemsPerRow?: number; + + /** + * Hide titles displayed for Artist Favorites media browser items. + * Default is false. + */ + artistFavBrowserItemsHideTitle?: boolean; + + /** + * Hide sub-titles displayed for Artist Favorites media browser items. + * Default is false. + */ + artistFavBrowserItemsHideSubTitle?: boolean; + + /** + * 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. + * Default is false. + */ + artistFavBrowserItemsSortTitle?: boolean; + + /** + * Title displayed at the top of the Audiobook Favorites media browser section form. + * Omit this parameter to hide the title display area. + * This value supports Title Formatter Options. + */ + audiobookFavBrowserTitle?: string; + + /** + * Sub-title displayed at the top of the Audiobook Favorites media browser section form. + * Omit this parameter to hide the sub-title display area. + * This value supports Title Formatter Options. + */ + audiobookFavBrowserSubTitle?: string; + + /** + * Number of items to display in a single row of the Audiobook Favorites media browser section form. + * Use a value of 1 to display the items as a vertical list. + * Default is 3. + */ + audiobookFavBrowserItemsPerRow?: number; + + /** + * Hide titles displayed for Audiobook Favorites media browser items. + * Default is false. + */ + audiobookFavBrowserItemsHideTitle?: boolean; + + /** + * Hide sub-titles displayed for Audiobook Favorites media browser items. + * Default is false. + */ + audiobookFavBrowserItemsHideSubTitle?: boolean; + + /** + * 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. + * Default is false. + */ + audiobookFavBrowserItemsSortTitle?: boolean; + + /** + * Title displayed at the top of the Device browser section form. + * Omit this parameter to hide the title display area. + * This value supports Title Formatter Options. + */ + deviceBrowserTitle?: string; + + /** + * Sub-title displayed at the top of the Device browser section form. + * Omit this parameter to hide the sub-title display area. + * This value supports Title Formatter Options. + */ + deviceBrowserSubTitle?: string; + + /** + * Number of items to display in a single row of the Device browser section form. + * Use a value of 1 to display the items as a vertical list. + * Default is 3. + */ + deviceBrowserItemsPerRow?: number; + + /** + * Hide titles displayed for Device browser items. + * Default is false. + */ + deviceBrowserItemsHideTitle?: boolean; + + /** + * Hide sub-titles displayed for Device browser items. + * Default is false. + */ + deviceBrowserItemsHideSubTitle?: boolean; + + /** + * Title displayed at the top of the Episode Favorites media browser section form. + * Omit this parameter to hide the title display area. + * This value supports Title Formatter Options. + */ + episodeFavBrowserTitle?: string; + + /** + * Sub-title displayed at the top of the Episode Favorites media browser section form. + * Omit this parameter to hide the sub-title display area. + * This value supports Title Formatter Options. + */ + episodeFavBrowserSubTitle?: string; + + /** + * Number of items to display in a single row of the Episode Favorites media browser section form. + * Use a value of 1 to display the items as a vertical list. + * Default is 3. + */ + episodeFavBrowserItemsPerRow?: number; + + /** + * Hide titles displayed for Episode Favorites media browser items. + * Default is false. + */ + episodeFavBrowserItemsHideTitle?: boolean; + + /** + * Hide sub-titles displayed for Episode Favorites media browser items. + * Default is false. + */ + episodeFavBrowserItemsHideSubTitle?: boolean; + + /** + * 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. + * Default is false. + */ + episodeFavBrowserItemsSortTitle?: boolean; + + /** + * Title displayed in the header area of the Player section form. + * Omit this parameter to hide the title display area. + * This value supports Title Formatter Options. + */ + playerHeaderTitle?: string; + + /** + * Artist and Track info displayed in the header area of the Player section form. + * Omit this parameter to hide this area. + * This value supports Title Formatter Options. + */ + playerHeaderArtistTrack?: string; + + /** + * Album info displayed in the header area of the Player section form. + * Omit this parameter to hide this area. + * This value supports Title Formatter Options. + */ + playerHeaderAlbum?: string; + + /** + * Text to display in the header area of the Player section form + * when no media is currently playing. + * Omit this parameter to display the default 'No Media Playing' value. + * This value supports Title Formatter Options. + */ + playerHeaderNoMediaPlayingText?: string; + + /** + * Hide progress bar in the header area of the Player section form. + * Default is false. + */ + playerHeaderHideProgressBar?: boolean; + + /** + * Color value (e.g. "#hhrrggbb") for the Player header area background gradient. + * Specify 'transparent' to hide the background area. + * Default is '#000000bb'. + */ + playerHeaderBackgroundColor?: string; + + /** + * Hide header area of the Player section form. + * Default is false. + */ + playerHeaderHide?: boolean; + + /** + * Hide favorites action button in the controls area of the Player section form. + * Default is false. + */ + playerControlsHideFavorites?: boolean; + + /** + * Hide play / pause button in the controls area of the Player section form. + * Default is false. + */ + playerControlsHidePlayPause?: boolean; + + /** + * Hide repeat button in the controls area of the Player section form. + * Default is false. + */ + playerControlsHideRepeat?: boolean; + + /** + * Hide shuffle button in the controls area of the Player section form. + * Default is false. + */ + playerControlsHideShuffle?: boolean; + + /** + * Hide next track button in the controls area of the Player section form. + * Default is false. + */ + playerControlsHideTrackNext?: boolean; + + /** + * Hide previous track button in the controls area of the Player section form. + * Default is false. + */ + playerControlsHideTrackPrev?: boolean; + + /** + * Hide controls area of the Player section form. + * Default is false. + */ + playerControlsHide?: boolean; + + /** + * Color value (e.g. "#hhrrggbb") for the Player controls area background gradient. + * Specify 'transparent' to hide the background area. + * Default is '#000000bb'. + */ + playerControlsBackgroundColor?: string; + + /** + * Hide mute button in the volume controls area of the Player section form. + * Default is false. + */ + playerVolumeControlsHideMute?: boolean; + + /** + * Hide power button in the volume controls area of the Player section form. + * Default is false. + */ + playerVolumeControlsHidePower?: boolean; + + /** + * Hide volume slider in the volume controls area of the Player section form. + * Default is false. + */ + playerVolumeControlsHideSlider?: boolean; + + /** + * Title displayed at the top of the Playlist Favorites media browser section form. + * Omit this parameter to hide the title display area. + * This value supports Title Formatter Options. + */ + playlistFavBrowserTitle?: string; + + /** + * Sub-title displayed at the top of the Playlist Favorites media browser section form. + * Omit this parameter to hide the sub-title display area. + * This value supports Title Formatter Options. + */ + playlistFavBrowserSubTitle?: string; + + /** + * Number of items to display in a single row of the Playlist Favorites media browser section form. + * Use a value of 1 to display the items as a vertical list. + * Default is 3. + */ + playlistFavBrowserItemsPerRow?: number; + + /** + * Hide titles displayed for Playlist Favorites media browser items. + * Default is false. + */ + playlistFavBrowserItemsHideTitle?: boolean; + + /** + * Hide sub-titles displayed for Playlist Favorites media browser items. + * Default is false. + */ + playlistFavBrowserItemsHideSubTitle?: boolean; + + /** + * 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. + * Default is false. + */ + playlistFavBrowserItemsSortTitle?: boolean; + + /** + * Title displayed at the top of the Recently Played media browser section form. + * Omit this parameter to hide the title display area. + * This value supports Title Formatter Options. + */ + recentBrowserTitle?: string; + + /** + * Sub-title displayed at the top of the Recently Played media browser section form. + * Omit this parameter to hide the sub-title display area. + * This value supports Title Formatter Options. + */ + recentBrowserSubTitle?: string; + + /** + * Number of items to display in a single row of the Recently Played media browser section form. + * Use a value of 1 to display the items as a vertical list. + * Default is 3. + */ + recentBrowserItemsPerRow?: number; + + /** + * Hide titles displayed for Recently Played media browser items. + * Default is false. + */ + recentBrowserItemsHideTitle?: boolean; + + /** + * Hide sub-titles displayed for Recently Played media browser items. + * Default is false. + */ + recentBrowserItemsHideSubTitle?: boolean; + + /** + * Title displayed at the top of the Search media browser section form. + * Omit this parameter to hide the title display area. + * This value supports Title Formatter Options. + */ + searchMediaBrowserTitle?: string; + + /** + * Sub-title displayed at the top of the Search media browser section form. + * Omit this parameter to hide the sub-title display area. + * This value supports Title Formatter Options. + */ + searchMediaBrowserSubTitle?: string; + + /** + * Use search display settings when displaying results + * If true, the search type values will be used for the ItemsPerRow, HideTitle, and HideSubTitle values. + * If false, the media type values will be used for the ItemsPerRow, HideTitle, and HideSubTitle values. + * Default is false. + */ + searchMediaBrowserUseDisplaySettings?: boolean; + + /** + * Number of items to display in a single row of the Search media browser section form. + * Use a value of 1 to display the items as a vertical list. + * Default is 3. + */ + searchMediaBrowserItemsPerRow?: number; + + /** + * Hide titles displayed for Search media browser items. + * Default is false. + */ + searchMediaBrowserItemsHideTitle?: boolean; + + /** + * Hide sub-titles displayed for Search media browser items. + * Default is false. + */ + searchMediaBrowserItemsHideSubTitle?: boolean; + + /** + * Maximum number of items to be returned by the search via the Search media browser section form. + * Default is 50. + */ + searchMediaBrowserSearchLimit?: number; + + /** + * True to sort displayed Search media browser item titles by name; + * Otherwise, False to display in the order returned from the Spotify Web API. + * Default is false. + */ + searchMediaBrowserItemsSortTitle?: boolean; + + /** + * Title displayed at the top of the Show Favorites media browser section form. + * Omit this parameter to hide the title display area. + * This value supports Title Formatter Options. + */ + showFavBrowserTitle?: string; + + /** + * Sub-title displayed at the top of the Show Favorites media browser section form. + * Omit this parameter to hide the sub-title display area. + * This value supports Title Formatter Options. + */ + showFavBrowserSubTitle?: string; + + /** + * Number of items to display in a single row of the Show Favorites media browser section form. + * Use a value of 1 to display the items as a vertical list. + * Default is 3. + */ + showFavBrowserItemsPerRow?: number; + + /** + * Hide titles displayed for Show Favorites media browser items. + * Default is false. + */ + showFavBrowserItemsHideTitle?: boolean; + + /** + * Hide sub-titles displayed for Show Favorites media browser items. + * Default is false. + */ + showFavBrowserItemsHideSubTitle?: boolean; + + /** + * 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. + * Default is false. + */ + showFavBrowserItemsSortTitle?: boolean; + + /** + * Title displayed at the top of the Track Favorites media browser section form. + * Omit this parameter to hide the title display area. + * This value supports Title Formatter Options. + */ + trackFavBrowserTitle?: string; + + /** + * Sub-title displayed at the top of the Track Favorites media browser section form. + * Omit this parameter to hide the sub-title display area. + * This value supports Title Formatter Options. + */ + trackFavBrowserSubTitle?: string; + + /** + * Number of items to display in a single row of the Track Favorites media browser section form. + * Use a value of 1 to display the items as a vertical list. + * Default is 3. + */ + trackFavBrowserItemsPerRow?: number; + + /** + * Hide titles displayed for Track Favorites media browser items. + * Default is false. + */ + trackFavBrowserItemsHideTitle?: boolean; + + /** + * Hide sub-titles displayed for Track Favorites media browser items. + * Default is false. + */ + trackFavBrowserItemsHideSubTitle?: boolean; + + /** + * 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. + * Default is false. + */ + trackFavBrowserItemsSortTitle?: boolean; + + /** + * Title displayed at the top of the Preset media browser section form. + * Omit this parameter to hide the title display area. + * This value supports Title Formatter Options. + */ + userPresetBrowserTitle?: string; + + /** + * Sub-title displayed at the top of the User Preset media browser section form. + * Omit this parameter to hide the sub-title display area. + * This value supports Title Formatter Options. + */ + userPresetBrowserSubTitle?: string; + + /** + * Number of items to display in a single row of the User Preset media browser section form. + * Use a value of 1 to display the items as a vertical list. + * Default is 3. + */ + userPresetBrowserItemsPerRow?: number; + + /** + * Hide titles displayed for User Preset media browser items. + * Default is false. + */ + userPresetBrowserItemsHideTitle?: boolean; + + /** + * Hide sub-titles displayed for User Preset media browser items. + * Default is false. + */ + userPresetBrowserItemsHideSubTitle?: boolean; + + /** + * Collection of custom imageUrl's that can be displayed in various media browser + * displays. This allows the user to override the image that is supplied by the + * media player service, as well as provide imageUrl's for items that do not contain + * an image. + * + * This configuration data must be configured manually in the card configuration. + * Some things to keep in mind when adding entries: + * - imageUrl titles are CaSe-SeNsItIvE. + * - imageUrl titles can contain special characters, but they are removed under the covers for the comparison process. + * - you can use "local" references for the imageUrl; any spaces in the filename are replaced with "%20". + * - you can use home assistant brands for the imageUrl; "logo.png" reference is replaced with "icon.png". + * - the "default" imageUrl title is used to supply an imageUrl for items that do not have an image. + * + * Example: + * customImageUrls: + * default: /local/images/spotifyplus_card_customimages/default.png + * empty preset: /local/images/spotifyplus_card_customimages/empty_preset.png + * Daily Mix 1: /local/images/spotifyplus_card_customimages/logo_spotify.png + * I Need You: https://i.scdn.co/image/ab67616d0000b2734bfd0e91bf806bc73d736cfd + * LiGhT rAiLs *?????: /local/images/spotifyplus_card_customimages/LiGhT rAiLs.png + * My Private Playlist: https://brands.home-assistant.io/spotifyplus/icon.png + * My Private Playlist2: https://brands.home-assistant.io/spotifyplus/logo.png + */ + customImageUrls?: CustomImageUrls; + + /** + * Collection of user-defined preset items that can be displayed in various media browser + * displays. This allows the user to define their own custom presets along with device presets. + * + * This configuration data must be configured manually in the card configuration. + * Some things to keep in mind when adding entries: + * - attribute names are are CaSe-SeNsItIvE. + * + * See wiki dicumentation for more examples. + * + * Example: + * userPresets: + * - name: "Spotify Playlist 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: ... + */ + userPresets?: Array; + + /** + * File path to a collection of user-defined preset items that can be displayed in various media browser + * displays. This allows the user to define their own custom presets along with device presets. + * + * See `userPresets` configuration item for file content format. + */ + userPresetsFile?: string; + + //imageUrlsReplaceHttpWithHttps?: boolean; +} diff --git a/src/types/class-element.ts b/src/types/class-element.ts new file mode 100644 index 0000000..c85a11f --- /dev/null +++ b/src/types/class-element.ts @@ -0,0 +1,11 @@ +export type Constructor = new (...args: any[]) => T; + +export interface ClassElement { + kind: "field" | "method"; + key: PropertyKey; + placement: "static" | "prototype" | "own"; + initializer?: (...args) => unknown; + extras?: ClassElement[]; + finisher?: (cls: Constructor) => undefined | Constructor; + descriptor?: PropertyDescriptor; +} diff --git a/src/types/config-area.ts b/src/types/config-area.ts new file mode 100644 index 0000000..e9681e2 --- /dev/null +++ b/src/types/config-area.ts @@ -0,0 +1,18 @@ +/** + * Configuration area editor sections enum. + */ +export enum ConfigArea { + ALBUM_FAVORITES = 'Albums', + ARTIST_FAVORITES = 'Artists', + AUDIOBOOK_FAVORITES = 'Audiobooks', + DEVICE_BROWSER = 'Devices', + EPISODE_FAVORITES = 'Episodes', + GENERAL = 'General', + PLAYER = 'Player', + PLAYLIST_FAVORITES = 'Playlists', + RECENT_BROWSER = 'Recents', + SEARCH_MEDIA_BROWSER = 'Search', + SHOW_FAVORITES = 'Shows', + TRACK_FAVORITES = 'Tracks', + USERPRESET_BROWSER = 'Presets', +} diff --git a/src/types/custom-image-urls.ts b/src/types/custom-image-urls.ts new file mode 100644 index 0000000..381333a --- /dev/null +++ b/src/types/custom-image-urls.ts @@ -0,0 +1,9 @@ +/** + * Custom ImageUrl configuration object. + * + * This interface contains the attributes and subitems that represent a + * custom image url configuration item. + */ +export interface CustomImageUrls { + [title: string]: string; +} diff --git a/src/types/hass-entity-attributes-media-player.ts b/src/types/hass-entity-attributes-media-player.ts new file mode 100644 index 0000000..5d5ce43 --- /dev/null +++ b/src/types/hass-entity-attributes-media-player.ts @@ -0,0 +1,43 @@ +import { HassEntityAttributeBase } from 'home-assistant-js-websocket'; +import { RepeatMode } from '../services/media-control-service'; + +/** + * MediaPlayer Hass Entity Attributes type. + * + * 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; + group_members?: [string]; + is_volume_muted?: boolean; + media_album_artist?: string; + media_album_name?: string; + media_artist?: string; + media_channel?: string; + media_content_id?: string; + media_content_type?: string; + media_duration?: number; + media_episode?: string; + media_image_hash?: string; + media_image_remotely_accessible?: boolean; + media_image_url?: string; + media_playlist?: string; + media_position_updated_at?: string; // dt.datetime | None = None + media_position?: number; + media_season?: string; + media_series_title?: string; + media_title?: string; + media_track?: number; + repeat?: RepeatMode; + shuffle?: boolean; + sound_mode_list?: [string]; + sound_mode?: string; + source_list?: [string]; + source?: string; + state?: string; // MediaPlayerState | None = None + supported_features?: number; // MediaPlayerEntityFeature(0) + volume_level?: number; +}; diff --git a/src/types/home-assistant-ex.ts b/src/types/home-assistant-ex.ts new file mode 100644 index 0000000..e86b7f9 --- /dev/null +++ b/src/types/home-assistant-ex.ts @@ -0,0 +1,73 @@ +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/media-browser-item.ts b/src/types/media-browser-item.ts new file mode 100644 index 0000000..627d2a9 --- /dev/null +++ b/src/types/media-browser-item.ts @@ -0,0 +1,40 @@ +/** + * Media Browser item information. + */ + +export interface IMediaBrowserInfo { + + /** + * Image url that will be displayed as the background image. + */ + image_url: string; + + + /** + * Title value. + */ + title: string | null; + + + /** + * Sub-Title value. + */ + subtitle: string | null; + + + /** + * Indicates if the item is the active item (true) or not (false). + */ + is_active: boolean | null; + +} + + +export interface IMediaBrowserItem { + + /** + * An IMediaBrowserItem that contains media browser item details. + */ + mbi_item: IMediaBrowserInfo; + +} \ No newline at end of file diff --git a/src/types/playerAlerts.ts b/src/types/playerAlerts.ts new file mode 100644 index 0000000..a59bb00 --- /dev/null +++ b/src/types/playerAlerts.ts @@ -0,0 +1,30 @@ +export interface playerAlerts { + + /** + * Clears the error alert text. + */ + alertErrorClear(): void; + + + /** + * Clears the informational alert text. + */ + alertInfoClear(): void; + + + /** + * Sets the alert info message. + * + * @param message alert message text. + */ + alertInfoSet(message: string): void; + + + /** + * Sets the alert error message. + * + * @param message alert message text. + */ + alertErrorSet(message: string): void; + +} diff --git a/src/types/search-media-types.ts b/src/types/search-media-types.ts new file mode 100644 index 0000000..9b8a3e8 --- /dev/null +++ b/src/types/search-media-types.ts @@ -0,0 +1,13 @@ +/** + * Search Media Types enum. + */ + +export enum SearchMediaTypes { + ALBUMS = 'Albums', + ARTISTS = 'Artists', + AUDIOBOOKS = 'AudioBooks', + EPISODES = 'Episodes', + PLAYLISTS = 'Playlists', + SHOWS = 'Shows', + TRACKS = 'Tracks', +} diff --git a/src/types/section.ts b/src/types/section.ts new file mode 100644 index 0000000..acfcc62 --- /dev/null +++ b/src/types/section.ts @@ -0,0 +1,20 @@ +/** + * Configuration area sections enum. + */ +export enum Section { + ALBUM_FAVORITES = 'albumfavorites', + ARTIST_FAVORITES = 'artistfavorites', + AUDIOBOOK_FAVORITES = 'audiobookfavorites', + DEVICES = 'devices', + EPISODE_FAVORITES = 'episodefavorites', + PLAYER = 'player', + PLAYLIST_FAVORITES = 'playlistfavorites', + RECENTS = 'recents', + SEARCH_MEDIA = 'searchmedia', + SHOW_FAVORITES = 'showfavorites', + TRACK_FAVORITES = 'trackfavorites', + USERPRESETS = 'userpresets', + /* the following are used to denote card configuration / setup issues. */ + UNDEFINED = 'undefined', + //INITIAL_CONFIG = 'initialconfig', +} diff --git a/src/types/service-call-request.ts b/src/types/service-call-request.ts new file mode 100644 index 0000000..fd79ae9 --- /dev/null +++ b/src/types/service-call-request.ts @@ -0,0 +1,19 @@ +/** + * Home Assistant Service Call Request object. + * + * This interface contains the attributes and subitems that represent a + * service call request. + */ +export declare type ServiceCallRequest = { + domain: string; + service: string; + serviceData?: Record; + target?: HassServiceTarget; +} + + +export declare type HassServiceTarget = { + entity_id?: string | string[]; + device_id?: string | string[]; + area_id?: string | string[]; +}; diff --git a/src/types/service-call-response.ts b/src/types/service-call-response.ts new file mode 100644 index 0000000..d5def0c --- /dev/null +++ b/src/types/service-call-response.ts @@ -0,0 +1,39 @@ +/** + * Home Assistant Service Call Response object. + * + * This interface contains the attributes and subitems that represent a + * service call response. + */ + +export interface ServiceCallResponse { + + /** + * Context in which the service was called. + */ + context: ServiceCallResponseContext; + + /** + * Response data returned by the called service (optional). + * This is usually a dictionary, but could also be basic types (string, number, etc). + */ + response?: Record; +} + + +export interface ServiceCallResponseContext { + + /** + * Context identifier (e.g. "01HW87T1D7C78YEQS2WZ5HNN2X"). + */ + id: string; + + /** + * Parent identifier (e.g. null). + */ + parent_id?: string; + + /** + * User identifier (e.g. "e6f0d061124b4c65abb00fa22e51f5a5"). + */ + user_id?: string | null; +} diff --git a/src/types/spotifyplus-hass-entity-attributes.ts b/src/types/spotifyplus-hass-entity-attributes.ts new file mode 100644 index 0000000..3d6f0e3 --- /dev/null +++ b/src/types/spotifyplus-hass-entity-attributes.ts @@ -0,0 +1,24 @@ +import { HassEntityAttributesMediaPlayer } from './hass-entity-attributes-media-player'; + +/** + * SpotifyPlus MediaPlayer Hass Entity Attributes type. + * + * Hass state attributes provided by the SpotifyPlus integration. + * This also contains the HA MediaPlayer attributes, as the SpotifyPlus + * integration inherits from HA MediaPlayer. + */ +export declare type SpotifyPlusHassEntityAttributes = HassEntityAttributesMediaPlayer & { + sp_device_id?: string; + sp_device_name?: string; + sp_device_is_brand_sonos?: string; + sp_context_uri?: string; + sp_item_type?: string; + sp_playlist_name?: string; + sp_playlist_uri?: string; + sp_user_country?: string; + sp_user_display_name?: string; + sp_user_email?: string; + sp_user_id?: string; + sp_user_product?: string; + sp_user_uri?: string; +}; diff --git a/src/types/spotifyplus-hass-entity.ts b/src/types/spotifyplus-hass-entity.ts new file mode 100644 index 0000000..be8918f --- /dev/null +++ b/src/types/spotifyplus-hass-entity.ts @@ -0,0 +1,18 @@ +import { Context } from 'home-assistant-js-websocket'; +import { SpotifyPlusHassEntityAttributes } from './spotifyplus-hass-entity-attributes'; + +/** + * SpotifyPlus MediaPlayer Hass Entity type. + * + * Hass state representation of a SpotifyPlus MediaPlayer integration. + * This is a copy of the HassEntityBase object, but with the `attributes` + * key mapped to the SpotifyPlusHassEntityAttributes type. + */ +export declare type SpotifyPlusHassEntity = { + entity_id: string; + state: string; + last_changed: string; + last_updated: string; + attributes: SpotifyPlusHassEntityAttributes; + context: Context; +}; \ No newline at end of file diff --git a/src/types/spotifyplus/album-page-saved.ts b/src/types/spotifyplus/album-page-saved.ts new file mode 100644 index 0000000..362826c --- /dev/null +++ b/src/types/spotifyplus/album-page-saved.ts @@ -0,0 +1,56 @@ +import { IPageObject } from './page-object'; +import { IAlbumSaved } from './album-saved'; +import { IAlbum } from './album'; + +/** + * Spotify Web API AlbumPageSaved object. + * + * This allows for multiple pages of `AlbumSaved` objects to be navigated. + */ +export interface IAlbumPageSaved extends IPageObject { + + + /** + * Array of `AlbumSaved` objects. + */ + 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. + * + * This is a convenience method so one does not have to loop through the `Items` + * array of AlbumSaved objects to get the list of albums. + * + * @returns a list of all albums contained in the underlying `Items` list. + */ + GetAlbums(): Array; + +} + + +/** + * Gets a list of all albums contained in the underlying `items` list. + * + * This is a convenience method so one does not have to loop through the `items` + * array of IAlbumSaved objects to get the list of albums. + * + * @returns An array of `IAlbum` objects that exist in the collection; otherwise, an empty array. + */ +export function GetAlbums(page: IAlbumPageSaved | IPageObject | undefined): Array { + + const result = new Array(); + if (page) { + for (const item of ((page as IAlbumPageSaved).items || [])) { + result.push(item.album); + } + } + return result +} diff --git a/src/types/spotifyplus/album-page-simplified.ts b/src/types/spotifyplus/album-page-simplified.ts new file mode 100644 index 0000000..90a53c7 --- /dev/null +++ b/src/types/spotifyplus/album-page-simplified.ts @@ -0,0 +1,44 @@ +import { IAlbumSimplified } from './album-simplified'; +import { IPageObject } from './page-object'; + +/** + * Spotify Web API SimplifiedAlbumPage object. + * + * This allows for multiple pages of `AlbumSimplified` objects to be navigated. + */ +export interface IAlbumPageSimplified extends IPageObject { + + + /** + * Array of `IAlbumSimplified` objects. + */ + 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; + +} + + +//class AlbumPageSimplified(PageObject): + +// def ContainsId(self, itemId:str=False) -> bool: +// """ +// Checks the `Items` collection to see if an item already exists with the +// specified Id value. + +// Returns True if the itemId exists in the collection; otherwise, False. +// """ +// result:bool = False + +// item:AlbumSimplified +// for item in self._Items: +// if item.Id == itemId: +// result = True +// break + +// return result diff --git a/src/types/spotifyplus/album-saved.ts b/src/types/spotifyplus/album-saved.ts new file mode 100644 index 0000000..69e254f --- /dev/null +++ b/src/types/spotifyplus/album-saved.ts @@ -0,0 +1,24 @@ +import { IAlbum } from './album'; + +/** + * Spotify Web API SavedAlbum object. + */ +export interface IAlbumSaved { + + + /** + * The date and time the album was saved. + * Timestamps are returned in ISO 8601 format as Coordinated Universal Time (UTC) with a zero + * offset: YYYY-MM-DDTHH:MM:SSZ. If the time is imprecise (for example, the date/time of an + * album release), an additional field indicates the precision; see for example, release_date + * in an album object. + */ + added_at: number; + + + /** + * Information about the album. + */ + album: IAlbum; + +} diff --git a/src/types/spotifyplus/album-simplified.ts b/src/types/spotifyplus/album-simplified.ts new file mode 100644 index 0000000..f9ba5f4 --- /dev/null +++ b/src/types/spotifyplus/album-simplified.ts @@ -0,0 +1,127 @@ +import { IArtistSimplified } from './artist-simplified'; +import { IExternalUrls } from './external-urls'; +import { IImageObject } from './image-object'; +import { IRestrictions } from './restrictions'; + +/** + * Spotify Web API Simplified Album object. + */ +export interface IAlbumSimplified { + + /** + * The type of the album. + * + * Allowed values: `album`, `single`, `compilation`. + * + * Example: `album` + */ + album_type: string; + + + /** + * The artists of the album. + * + * Each artist object includes a link in href to more detailed information about the artist. + */ + artists: Array + + + /** + * The markets in which the album is available: ISO 3166-1 alpha-2 country codes. + * + * NOTE: an album is considered available in a market when at least 1 of its tracks is available in that market. + * + * Example: `["CA","BR","IT"]` + */ + available_markets: Array + + + /** + * Known external URLs for this album. + */ + external_urls: IExternalUrls; + + + /** + * A link to the Web API endpoint providing full details of the album. + */ + href: string; + + + /** + * The Spotify user ID for the album. + * Example: `2up3OPMp9Tb4dAKM2erWXQ` + */ + id: string; + + + /** + * Images for the album. + * + * The array may be empty or contain up to three images. + * The images are returned by size in descending order. + * Note: If returned, the source URL for the image (url) is temporary and will expire in less than a day. + */ + images: Array; + + + /** + * Image to use for media browser displays. + * + * 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; + + + /** + * The name of the album. + * + * In case of an album takedown, the value may be an empty string. + */ + name: string; + + + /** + * The date the album was first released. + * + * Example: `1981-12` + */ + release_date: string; + + + /** + * The precision with which release_date value is known. + * Allowed values: `year`, `month`, `day`. + * + * Example: `year` + */ + release_date_precision: string; + + + /** + * Included in the response when a content restriction is applied. + */ + restrictions: IRestrictions; + + + /** + * The number of tracks in the album. + */ + total_tracks: number; + + + /** + * The object type: `album`. + */ + type: string; + + + /** + * The Spotify URI for the album. + * + * Example: `spotify:album:2up3OPMp9Tb4dAKM2erWXQ` + */ + uri: string; + +} diff --git a/src/types/spotifyplus/album.ts b/src/types/spotifyplus/album.ts new file mode 100644 index 0000000..b99524d --- /dev/null +++ b/src/types/spotifyplus/album.ts @@ -0,0 +1,52 @@ +import { IAlbumSimplified } from './album-simplified'; +import { ICopyright } from './copyright'; +import { IExternalUrls } from './external-urls'; +import { ITrackPageSimplified } from './track-page-simplified'; + +/** + * Spotify Web API IAlbum object. + */ +export interface IAlbum extends IAlbumSimplified { + + + /** + * The copyright statements of the album. + */ + copyrights: Array; + + + /** + * Known external url's for the album. + */ + external_ids: IExternalUrls; + + + /** + * A list of the genres the album is associated with. If not yet classified, the array is empty. + * Example: `["Egg punk","Noise rock"]` + */ + genres: Array; + + + /** + * The label associated with the album. + */ + label: string; + + + /** + * The popularity of the album. + * + * The value will be between 0 and 100, with 100 being the most popular. + */ + popularity: number; + + + /** + * The tracks of the album. + * + * This is a `TrackPageSimplified` object, meaning only 50 tracks max are listed per request. + */ + tracks?: ITrackPageSimplified; + +} diff --git a/src/types/spotifyplus/artist-info-tour-event.ts b/src/types/spotifyplus/artist-info-tour-event.ts new file mode 100644 index 0000000..87aa562 --- /dev/null +++ b/src/types/spotifyplus/artist-info-tour-event.ts @@ -0,0 +1,25 @@ +/** + * Artist Information Tour object. + */ +export interface IArtistInfoTourEvent { + + + /** + * Date and time the tour event starts in the local timezone of the + * venue location, if supplied; otherwise, null. + */ + event_datetime: Date | null; + + + /** + * Title given to the event by the promoter, if supplied; otherwise, null. + */ + title: string | null; + + + /** + * The venue name of where the event will take place, if supplied; otherwise, null. + */ + venue_name: string | null; + +} diff --git a/src/types/spotifyplus/artist-info.ts b/src/types/spotifyplus/artist-info.ts new file mode 100644 index 0000000..1846897 --- /dev/null +++ b/src/types/spotifyplus/artist-info.ts @@ -0,0 +1,87 @@ +import { IArtistInfoTourEvent } from './artist-info-tour-event'; + +/** + * Artist Information About object. + */ +export interface IArtistInfo { + + + /** + * URL link to artist Facebook page, if supplied; otherwise, null. + */ + about_url_facebook: string | null; + + + /** + * URL link to artist Instagram page, if supplied; otherwise, null. + */ + about_url_instagram: string | null; + + + /** + * URL link to artist Twitter page, if supplied; otherwise, null. + */ + about_url_twitter: string | null; + + + /** + * URL link to artist Wikipedia page, if supplied; otherwise, null. + */ + about_url_wikipedia: string | null; + + + /** + * Biography text. + */ + bio: string | null; + + + /** + * The Spotify ID for the artist. + */ + id: string; + + + /** + * Image url of the artist, if defined; otheriwse, the `ImageUrlDefault` url value. + */ + image_url: string | null; + + + /** + * Default Image url of the artist, if defined; otherwise, null. + */ + image_url_default: string | null; + + + /** + * The name of the artist. + */ + name: string; + + + /** + * Monthly Listeners text. + */ + monthly_listeners: number; + + + /** + * An array of `ArtistInfoTourEvent` objects, if the artist has any upcoming tour + * dates on file; otherwise, an empty list. + */ + tour_events: Array; + + + /** + * The object type: `artist`. + */ + type: string; + + + /** + * The Spotify URI for the artist. + */ + uri: string; + +} diff --git a/src/types/spotifyplus/artist-page.ts b/src/types/spotifyplus/artist-page.ts new file mode 100644 index 0000000..ce4df4b --- /dev/null +++ b/src/types/spotifyplus/artist-page.ts @@ -0,0 +1,24 @@ +import { IArtist } from './artist'; +import { IPageObject } from './page-object'; + +/** + * Spotify Web API ArtistPage object. + * + * This allows for multiple pages of `Artist` objects to be navigated. + */ +export interface IArtistPage extends IPageObject { + + + /** + * Array of `Artist` objects. + */ + 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-simplified.ts b/src/types/spotifyplus/artist-simplified.ts new file mode 100644 index 0000000..deaf5a4 --- /dev/null +++ b/src/types/spotifyplus/artist-simplified.ts @@ -0,0 +1,46 @@ +import { IExternalUrls } from './external-urls'; + +/** + * Spotify Web API SimplifiedArtist object. + */ +export interface IArtistSimplified { + + /** + * Known external URLs for this artist. + */ + external_urls: IExternalUrls; + + + /** + * A link to the Web API endpoint providing full details of the artist. + */ + href: string; + + + /** + * The Spotify ID for the artist. + * Example: `2CIMQHirSU0MQqyYHq0eOx` + */ + id: string; + + + /** + * The name of the artist. + */ + name: string; + + + /** + * The object type: `artist`. + */ + type: string; + + + /** + * The Spotify URI for the artist. + * + * Example: `spotify:artist:2CIMQHirSU0MQqyYHq0eOx` + */ + uri: string; + +} \ No newline at end of file diff --git a/src/types/spotifyplus/artist.ts b/src/types/spotifyplus/artist.ts new file mode 100644 index 0000000..6932664 --- /dev/null +++ b/src/types/spotifyplus/artist.ts @@ -0,0 +1,47 @@ +import { IArtistSimplified } from './artist-simplified'; +import { IFollowers } from './followers'; +import { IImageObject } from './image-object'; + +/** + * Spotify Web API Artist object. + */ +export interface IArtist extends IArtistSimplified { + + + /** + * Information about the followers of the artist. + */ + followers: IFollowers; + + + /** + * A list of the genres the artist is associated with; if not yet classified, the array is empty. + * Example: `["Prog rock","Grunge"]` + */ + genres: Array; + + + /** + * Images of the artist in various sizes, widest first. + */ + images: Array; + + + /** + * Image to use for media browser displays. + * + * 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; + + + /** + * The popularity of the artist. + * + * The value will be between 0 and 100, with 100 being the most popular. + * The artist's popularity is calculated from the popularity of all the artist's tracks. + */ + popularity: number; + +} diff --git a/src/types/spotifyplus/audiobook-page-simplified.ts b/src/types/spotifyplus/audiobook-page-simplified.ts new file mode 100644 index 0000000..ab4ecb2 --- /dev/null +++ b/src/types/spotifyplus/audiobook-page-simplified.ts @@ -0,0 +1,34 @@ +import { IAudiobookSimplified } from './audiobook-simplified'; +import { IPageObject } from './page-object'; + +/** + * Spotify Web API SimplifiedAudiobookPage object. + * + * This allows for multiple pages of `AudiobookSimplified` objects to be navigated. + */ +export interface IAudiobookPageSimplified extends IPageObject { + + + /** + * Array of `IAudiobookSimplified` objects. + */ + 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. + * + * @param itemId ID of the item to check for. + * @returns True if the itemId exists in the collection; otherwise, False. + */ + ContainsId(itemId: string): boolean; + +} diff --git a/src/types/spotifyplus/audiobook-simplified.ts b/src/types/spotifyplus/audiobook-simplified.ts new file mode 100644 index 0000000..56ad5ee --- /dev/null +++ b/src/types/spotifyplus/audiobook-simplified.ts @@ -0,0 +1,220 @@ +import { IAuthor } from './author'; +import { ICopyright } from './copyright'; +import { IExternalUrls } from './external-urls'; +import { IImageObject } from './image-object'; +import { INarrator } from './narrator'; + +/** + * Spotify Web API Simplified Audiobook object. + */ +export interface IAudiobookSimplified { + + + /** + * The author(s) for the audiobook. + */ + authors: Array; + + + /** + * A list of the countries in which the audiobook can be played, identified by their ISO 3166-1 alpha-2 code. + */ + available_markets: Array; + + + /** + * The copyright statements of the audiobook. + */ + copyrights: Array; + + + /** + * A description of the audiobook. + * + * HTML tags are stripped away from this field, use html_description field in case HTML tags are needed. + */ + description: string; + + + /** + * The edition of the audiobook. + * Example: `Unabridged` + */ + edition: string; + + + /** + * Whether or not the audiobook has explicit content (true = yes it does; false = no it does not OR unknown). + */ + explicit: boolean; + + + /** + * Known external url's for the audiobook. + */ + external_urls: IExternalUrls; + + + /** + * A link to the Web API endpoint providing full details of the audiobook. + * Example: `https://api.spotify.com/v1/audiobooks/7iHfbu1YPACw6oZPAFJtqe` + */ + href: string; + + + /** + * A description of the audiobook. This field may contain HTML tags. + */ + html_description: string; + + + /** + * The Spotify ID for the audiobook. + * Example: `7iHfbu1YPACw6oZPAFJtqe` + */ + id: string; + + + /** + * The cover art for the audiobook in various sizes, widest first. + */ + images: Array; + + + /** + * The first image url in the `Images` list, if images are defined; + * otherwise, null. + */ + image_url?: string | undefined; + + + /** + * A list of the languages used in the audiobook, identified by their ISO 639-1 code. + * Example: `[fr,en]` + */ + languages: Array; + + + /** + * The media type of the audiobook. + * Example: `audio` + */ + media_type: string; + + + /** + * The name of the audiobook. + */ + name: string; + + + /** + * The narrator(s) for the audiobook. + */ + narrators: Array; + + + /** + * The publisher of the audiobook. + */ + publisher: string; + + + /** + * The number of chapters in the audiobook. + */ + total_chapters: number; + + + /** + * The object type: `audiobook`. + */ + type: string; + + + /** + * The Spotify URI for the audiobook. + * + * Example: `spotify:audiobook:7iHfbu1YPACw6oZPAFJtqe` + */ + uri: string; + +} + + +/** + * Gets a user-friendly description of the `authors` object(s). + * + * @param mediaItem Media item that contains a authors property. + * @returns A string that contains a user-friendly description of the authors. + */ +export function GetAudiobookAuthors(mediaItem: IAudiobookSimplified | undefined, delimiter: string): string { + + if (delimiter == null) + delimiter = "; "; + + let result = ""; + if (mediaItem) { + for (const item of mediaItem.authors || []) { + if ((item != null) && (item.name != null) && (item.name.length > 0)) { + if (result.length > 0) + result += delimiter; + result += item.name; + } + } + } + + return result +} + + +/** + * Gets a user-friendly description of the `copyrights` object(s). + * + * @param mediaItem Media item that contains a copyrights property. + * @returns A string that contains a user-friendly description of the copyrights. + */ +export function GetAudiobookCopyrights(mediaItem: IAudiobookSimplified | undefined, delimiter: string): string { + + if (delimiter == null) + delimiter = "; "; + + let result = ""; + if (mediaItem) { + for (const item of mediaItem.copyrights || []) { + if ((item != null) && (item.text != null) && (item.text.length > 0)) { + if (result.length > 0) + result += delimiter; + result += item.text; + } + } + } + + return result +} + + +/** + * Gets a user-friendly description of the `narrator` object(s). + * + * @param mediaItem Media item that contains a narrators property. + * @returns A string that contains a user-friendly description of the narrators. + */ +export function GetAudiobookNarrators(mediaItem: IAudiobookSimplified | undefined, delimiter: string): string { + + if (delimiter == null) + delimiter = "; "; + + let result = ""; + if (mediaItem) { + for (const item of mediaItem.narrators || []) { + if ((item != null) && (item.name != null) && (item.name.length > 0)) { + if (result.length > 0) + result += delimiter; + result += item.name; + } + } + } + + return result +} diff --git a/src/types/spotifyplus/audiobook.ts b/src/types/spotifyplus/audiobook.ts new file mode 100644 index 0000000..bc8df75 --- /dev/null +++ b/src/types/spotifyplus/audiobook.ts @@ -0,0 +1,17 @@ +import { IAudiobookSimplified } from './audiobook-simplified'; +import { IChapterPageSimplified } from './chapter-page-simplified'; + +/** + * Spotify Web API Audiobook object. + */ +export interface IAudiobook extends IAudiobookSimplified { + + + /** + * The chapters of the audiobook. + * + * This is a `IChapterPageSimplified` object. + */ + chapters?: Array; + +} diff --git a/src/types/spotifyplus/author.ts b/src/types/spotifyplus/author.ts new file mode 100644 index 0000000..9bbd5b9 --- /dev/null +++ b/src/types/spotifyplus/author.ts @@ -0,0 +1,14 @@ +/** + * Spotify Web API Content Author object. + * + * Contains information about content authors. + */ +export interface IAuthor { + + + /** + * The author name for this content. + */ + name: string; + +} diff --git a/src/types/spotifyplus/chapter-page-simplified.ts b/src/types/spotifyplus/chapter-page-simplified.ts new file mode 100644 index 0000000..09786e6 --- /dev/null +++ b/src/types/spotifyplus/chapter-page-simplified.ts @@ -0,0 +1,34 @@ +import { IChapterSimplified } from './chapter-simplified'; +import { IPageObject } from './page-object'; + +/** + * Spotify Web API SimplifiedChapterPage object. + * + * This allows for multiple pages of `ChapterSimplified` objects to be navigated. + */ +export interface IChapterPageSimplified extends IPageObject { + + + /** + * Array of `IChapterSimplified` objects. + */ + 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. + * + * @param itemId ID of the item to check for. + * @returns True if the itemId exists in the collection; otherwise, False. + */ + ContainsId(itemId: string): boolean; + +} diff --git a/src/types/spotifyplus/chapter-simplified.ts b/src/types/spotifyplus/chapter-simplified.ts new file mode 100644 index 0000000..576f1d2 --- /dev/null +++ b/src/types/spotifyplus/chapter-simplified.ts @@ -0,0 +1,156 @@ +import { IExternalUrls } from './external-urls'; +import { IImageObject } from './image-object'; +import { IRestrictions } from './restrictions'; +import { IResumePoint } from './resume-point'; + +/** + * Spotify Web API Simplified Chapter object. + */ +export interface IChapterSimplified { + + + /** + * A URL to a 30 second preview (MP3 format) of the chapter, or null if not available. + * + * Example: `https://p.scdn.co/mp3-preview/2f37da1d4221f40b9d1a98cd191f4d6f1646ad17` + */ + audio_preview_url: string; + + + /** + * A list of the countries in which the chapter can be played, identified by their ISO 3166-1 alpha-2 code. + */ + available_markets: Array; + + + /** + * The number of the chapter. + * Example: `1` + */ + chapter_number: number; + + + /** + * A description of the chapter. + * + * HTML tags are stripped away from this field, use html_description field in case HTML tags are needed. + */ + description: string; + + + /** + * The chapter length in milliseconds. + * Example: `1686230` + */ + duration_ms: number; + + + /** + * Whether or not the chapter has explicit content (true = yes it does; false = no it does not OR unknown). + */ + explicit: boolean; + + + /** + * Known external url's for the chapter. + */ + external_urls: IExternalUrls; + + + /** + * A link to the Web API endpoint providing full details of the chapter. + * Example: `https://api.spotify.com/v1/chapters/0D5wENdkdwbqlrHoaJ9g29` + */ + href: string; + + + /** + * A description of the chapter. This field may contain HTML tags. + */ + html_description: string; + + + /** + * The Spotify ID for the chapter. + * Example: `0D5wENdkdwbqlrHoaJ9g29` + */ + id: string; + + + /** + * The cover art for the chapter in various sizes, widest first. + */ + images: Array; + + + /** + * The first image url in the `Images` list, if images are defined; + * otherwise, null. + */ + image_url?: string | undefined; + + + /** + * True if the chapter is playable in the given market. Otherwise false. + */ + is_playable: boolean; + + + /** + * A list of the languages used in the chapter, identified by their ISO 639-1 code. + * Example: `[fr,en]` + */ + languages: Array; + + + /** + * The name of the chapter. + */ + name: string; + + + /** + * The date the chapter was first released. + * + * Example: `1981-12` + * Depending on the precision, it might be shown as "1981" or "1981-12". + */ + release_date: string; + + + /** + * The precision with which release_date value is known. + * Allowed values: `year`, `month`, `day`. + * + * Example: `year` + */ + release_date_precision: string; + + + /** + * Included in the response when a content restriction is applied. + */ + restrictions: IRestrictions; + + + /** + * The user's most recent position in the chapter. + * Set if the supplied access token is a user token and has the scope 'user-read-playback-position'. + */ + resume_point: IResumePoint; + + + /** + * The object type: `chapter`. + */ + type: string; + + + /** + * The Spotify URI for the chapter. + * + * Example: `spotify:chapter:0D5wENdkdwbqlrHoaJ9g29` + */ + uri: string; + +} diff --git a/src/types/spotifyplus/chapter.ts b/src/types/spotifyplus/chapter.ts new file mode 100644 index 0000000..77c93e0 --- /dev/null +++ b/src/types/spotifyplus/chapter.ts @@ -0,0 +1,17 @@ +import { IChapterSimplified } from './chapter-simplified'; +import { IAudiobookSimplified } from './audiobook-simplified'; + +/** + * Spotify Web API Chapter object. + */ +export interface IChapter extends IChapterSimplified { + + + /** + * The audiobook for which the chapter belongs. + * + * This is a `IAudiobookSimplified` object. + */ + audiobook: IAudiobookSimplified; + +} diff --git a/src/types/spotifyplus/context.ts b/src/types/spotifyplus/context.ts new file mode 100644 index 0000000..c1bc0d9 --- /dev/null +++ b/src/types/spotifyplus/context.ts @@ -0,0 +1,33 @@ +import { IExternalUrls } from './external-urls'; + +/** + * Spotify Web API Context object. + */ +export interface IContext { + + /** + * Known external URLs for this context. + */ + external_urls: IExternalUrls; + + + /** + * A link to the Web API endpoint providing full details of the context. + */ + href: string; + + + /** + * Object type, such as `artist`, `playlist`, `album` or `show`. + */ + type: string; + + + /** + * The Spotify URI for the context. + * + * Example: `spotify:album:2up3OPMp9Tb4dAKM2erWXQ` + */ + uri: string; + +} diff --git a/src/types/spotifyplus/copyright.ts b/src/types/spotifyplus/copyright.ts new file mode 100644 index 0000000..639e459 --- /dev/null +++ b/src/types/spotifyplus/copyright.ts @@ -0,0 +1,46 @@ +/** + * Spotify Web API Content Copyright object. + * + * Contains information about content copyrights. + */ +export interface ICopyright { + + + /** + * The copyright text for this content. + */ + text: string; + + + /** + * The type of copyright: C = the copyright, P = the sound recording (performance) copyright. + */ + type: string; + +} + + +/** + * Gets a user-friendly description of the `copyrights` object(s). + * + * @param mediaItem Media item that contains a copyrights property. + * @returns A string that contains a user-friendly description of the copyrights. + */ +export function GetCopyrights(mediaItem: any | undefined, delimiter: string): string { + + if (delimiter == null) + delimiter = "; "; + + let result = ""; + if (mediaItem) { + for (const item of mediaItem.copyrights || []) { + if ((item != null) && (item.text != null) && (item.text.length > 0)) { + if (result.length > 0) + result += delimiter; + result += item.text; + } + } + } + + return result +} diff --git a/src/types/spotifyplus/device.ts b/src/types/spotifyplus/device.ts new file mode 100644 index 0000000..467f629 --- /dev/null +++ b/src/types/spotifyplus/device.ts @@ -0,0 +1,109 @@ +/** + * Spotify Web API Device object. + */ +export interface IDevice { + + /** + * The device ID. + * + * This ID is unique and persistent to some extent. However, this is not guaranteed + * and any cached device_id should periodically be cleared out and refetched as necessary. + */ + id: string; + + + /** + * Image to use for media browser displays. + * + * 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; + + + /** + * If this device is the currently active device. + */ + is_active: boolean; + + + /** + * True if the player device volume is zero (muted); + * otherwise, false. + */ + is_muted: string; + + + /** + * If this device is currently in a private session. + */ + is_private_session: boolean; + + + /** + * Whether controlling this device is restricted. + * + * At present if this is "true" then no Web API commands will be accepted by this device. + */ + is_restricted: boolean; + + + /** + * A human-readable name for the device. + * + * Some devices have a name that the user can configure (e.g. "Loudest speaker") and some + * devices have a generic name associated with the manufacturer or device model. + * + * Example: `Kitchen Speaker` + */ + name: string; + + + /** + * Returns a string that can be used in a selection list in the + * form of "Name (Id)". + */ + select_item_name_and_id: string; + + + /** + * If this device can be used to set the volume. + */ + supports_volume: boolean; + + + /** + * Device type, such as `computer`, `smartphone` or `speaker`. + * + * Example: `computer` + */ + type: string; + + + /** + * The current volume in percent. + * + * Range: `0 - 100` + * Example: 59 + */ + volume_percent: number; + + + /** + * Returns the Id portion of a `SelectItemNameAndId` property value. + * + * @value A `SelectItemNameAndId` property value. + * @returns The Id portion of a `SelectItemNameAndId` property value, or None if the Id portion could not be determined. + */ + GetIdFromSelectItem(value: string): string; + + + /** + * Returns the Name portion of a `SelectItemNameAndId` property value. + * + * @value A `SelectItemNameAndId` property value. + * @returns The Name portion of a `SelectItemNameAndId` property value, or None if the Name portion could not be determined. + */ + GetNameFromSelectItem(value: string): string; + +} diff --git a/src/types/spotifyplus/episode-page-saved.ts b/src/types/spotifyplus/episode-page-saved.ts new file mode 100644 index 0000000..91cb703 --- /dev/null +++ b/src/types/spotifyplus/episode-page-saved.ts @@ -0,0 +1,57 @@ +import { IPageObject } from './page-object'; +import { IEpisode } from './episode'; +import { IEpisodeSaved } from './episode-saved'; + +/** + * Spotify Web API EpisodePageSaved object. + * + * This allows for multiple pages of `EpisodeSaved` objects to be navigated. + */ +export interface IEpisodePageSaved extends IPageObject { + + + /** + * Array of `IEpisodeSaved` objects. + */ + 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. + * + * This is a convenience method so one does not have to loop through the `Items` + * array of `IEpisodeSaved` objects to get the list of episodes. + * + * @returns An array of `IEpisode` objects that exist in the collection; otherwise, an empty array. + */ + GetEpisodes(): Array; + +} + + +/** + * Gets a list of all episodes contained in the underlying `Items` list. + * + * This is a convenience method so one does not have to loop through the `Items` + * array of `IEpisodeSaved` objects to get the list of episodes. + * + * @returns An array of `IEpisode` objects that exist in the collection; otherwise, an empty array. +*/ +export function GetEpisodes(page: IEpisodePageSaved | IPageObject | undefined): Array { + + const result = new Array(); + if (page) { + for (const item of ((page as IEpisodePageSaved).items || [])) { + result.push(item.episode); + } + } + return result +} + diff --git a/src/types/spotifyplus/episode-page-simplified.ts b/src/types/spotifyplus/episode-page-simplified.ts new file mode 100644 index 0000000..d6d6988 --- /dev/null +++ b/src/types/spotifyplus/episode-page-simplified.ts @@ -0,0 +1,34 @@ +import { IEpisodeSimplified } from './episode-simplified'; +import { IPageObject } from './page-object'; + +/** + * Spotify Web API SimplifiedEpisodePage object. + * + * This allows for multiple pages of `EpisodeSimplified` objects to be navigated. + */ +export interface IEpisodePageSimplified extends IPageObject { + + + /** + * Array of `IEpisodeSimplified` objects. + */ + 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. + * + * @param itemId ID of the item to check for. + * @returns True if the itemId exists in the collection; otherwise, False. + */ + ContainsId(itemId: string): boolean; + +} diff --git a/src/types/spotifyplus/episode-saved.ts b/src/types/spotifyplus/episode-saved.ts new file mode 100644 index 0000000..ac4aa56 --- /dev/null +++ b/src/types/spotifyplus/episode-saved.ts @@ -0,0 +1,25 @@ +import { IEpisode } from './episode'; + +/** + * Spotify Web API SavedEpisode object. + */ +export interface IEpisodeSaved { + + + /** + * The date and time the episode was saved. + * + * Timestamps are returned in ISO 8601 format as Coordinated Universal Time (UTC) with a zero + * offset: YYYY-MM-DDTHH:MM:SSZ. If the time is imprecise (for example, the date/time of an + * episode release), an additional field indicates the precision; see for example, release_date + * in an episode object. + */ + added_at: number; + + + /** + * Information about the episode. + */ + episode: IEpisode; + +} diff --git a/src/types/spotifyplus/episode-simplified.ts b/src/types/spotifyplus/episode-simplified.ts new file mode 100644 index 0000000..cd29136 --- /dev/null +++ b/src/types/spotifyplus/episode-simplified.ts @@ -0,0 +1,152 @@ +import { IExternalUrls } from './external-urls'; +import { IImageObject } from './image-object'; +import { IRestrictions } from './restrictions'; +import { IResumePoint } from './resume-point'; + +/** + * Spotify Web API Simplified Episode object. + */ +export interface IEpisodeSimplified { + + + /** + * A URL to a 30 second preview (MP3 format) of the episode, or null if not available. + * + * Important policy note: + * Spotify Audio preview clips can not be a standalone service. + * + * Example: `https://p.scdn.co/mp3-preview/2f37da1d4221f40b9d1a98cd191f4d6f1646ad17` + */ + audio_preview_url: string; + + + /** + * A description of the episode. + * + * HTML tags are stripped away from this field, use html_description field in case HTML tags are needed. + */ + description: string; + + + /** + * The episode length in milliseconds. + * Example: `1686230` + */ + duration_ms: number; + + + /** + * Whether or not the episode has explicit content (true = yes it does; false = no it does not OR unknown). + */ + explicit: boolean; + + + /** + * Known external url's for the episode. + */ + external_urls: IExternalUrls; + + + /** + * A link to the Web API endpoint providing full details of the episode. + * Example: `https://api.spotify.com/v1/episodes/5Xt5DXGzch68nYYamXrNxZ` + */ + href: string; + + + /** + * A description of the episode. This field may contain HTML tags. + */ + html_description: string; + + + /** + * The Spotify ID for the episode. + * Example: `5Xt5DXGzch68nYYamXrNxZ` + */ + id: string; + + + /** + * The cover art for the episode in various sizes, widest first. + */ + images: Array; + + + /** + * The first image url in the `Images` list, if images are defined; + * otherwise, null. + */ + image_url?: string | undefined; + + + /** + * True if the episode is hosted outside of Spotify's CDN. + */ + is_externally_hosted: boolean; + + + /** + * True if the episode is playable in the given market. Otherwise false. + */ + is_playable: boolean; + + + /** + * A list of the languages used in the episode, identified by their ISO 639-1 code. + * Example: `[fr,en]` + */ + languages: Array; + + + /** + * The name of the episode. + */ + name: string; + + + /** + * The date the episode was first released. + * + * Example: `1981-12` + * Depending on the precision, it might be shown as "1981" or "1981-12". + */ + release_date: string; + + + /** + * The precision with which release_date value is known. + * Allowed values: `year`, `month`, `day`. + * + * Example: `year` + */ + release_date_precision: string; + + + /** + * Included in the response when a content restriction is applied. + */ + restrictions: IRestrictions; + + + /** + * The user's most recent position in the episode. + * Set if the supplied access token is a user token and has the scope 'user-read-playback-position'. + */ + resume_point: IResumePoint; + + + /** + * The object type: `episode`. + */ + type: string; + + + /** + * The Spotify URI for the episode. + * + * Example: `spotify:episode:5Xt5DXGzch68nYYamXrNxZ` + */ + uri: string; + +} diff --git a/src/types/spotifyplus/episode.ts b/src/types/spotifyplus/episode.ts new file mode 100644 index 0000000..79ed6b2 --- /dev/null +++ b/src/types/spotifyplus/episode.ts @@ -0,0 +1,37 @@ +import { IEpisodeSimplified } from './episode-simplified'; +import { IShowSimplified } from './show-simplified'; + +/** + * Spotify Web API Episode object. + */ +export interface IEpisode extends IEpisodeSimplified { + + + /** + * The show on which the episode belongs. + * + * This is a `IShowSimplified` object. + */ + show: IShowSimplified; + +} + + + +/** + * Returns True if an object implements the `show` property of the IEpisode interface. + * + * @param obj Object to check. + * @returns True if `obj` object implements the `show` property of the IEpisode interface. + */ +export function isEpisodeObject(obj: any): obj is IEpisode { + + if (typeof obj !== 'object' || obj === null || !('show' in obj)) { + //console.log("%c Object is NOT an IEpisode!", "color:yellow"); + return false; // object does not implement interface IEpisode + } + + //console.log("%c Object is an IEpisode!", "color:yellow"); + return true; + +} diff --git a/src/types/spotifyplus/external-urls.ts b/src/types/spotifyplus/external-urls.ts new file mode 100644 index 0000000..a16f8a5 --- /dev/null +++ b/src/types/spotifyplus/external-urls.ts @@ -0,0 +1,32 @@ +/** + * Spotify Web API ExternalUrls object. + * + * Contains known external URLs for various object types: artist, track, etc. + */ +export interface IExternalUrls { + + + /** + * International Article Number. + * */ + ean?: string; + + + /** + * International Standard Recording Code. + * */ + isrc?: string; + + + /** + * The Spotify URL for the object. + * */ + spotify?: string; + + + /** + * Universal Product Code. + * */ + upc?: string; + +} \ No newline at end of file diff --git a/src/types/spotifyplus/followers.ts b/src/types/spotifyplus/followers.ts new file mode 100644 index 0000000..13de253 --- /dev/null +++ b/src/types/spotifyplus/followers.ts @@ -0,0 +1,21 @@ +/** + * Spotify Web API Followers object. + * + * Contains information about the followers of an artist. + */ +export interface IFollowers { + + + /** + * This will always be set to null, as the Web API does not support it at the moment. + * */ + href: string; + + + /** + * The total number of followers. + * Example: 31288 + * */ + total: number; + +} \ No newline at end of file diff --git a/src/types/spotifyplus/image-object.ts b/src/types/spotifyplus/image-object.ts new file mode 100644 index 0000000..8c1588a --- /dev/null +++ b/src/types/spotifyplus/image-object.ts @@ -0,0 +1,27 @@ +/** + * Spotify Web API Image object. + */ +export interface IImageObject { + + + /** + * The image height in pixels. + * Example: 300 + * */ + height: number; + + + /** + * The source URL of the image. + * Example: `https://i.scdn.co/image/ab67616d00001e02ff9ca10b55ce82ae553c8228` + * */ + url: string; + + + /** + * The image width in pixels. + * Example: 300 + * */ + width: number; + +} \ No newline at end of file diff --git a/src/types/spotifyplus/narrator.ts b/src/types/spotifyplus/narrator.ts new file mode 100644 index 0000000..171779b --- /dev/null +++ b/src/types/spotifyplus/narrator.ts @@ -0,0 +1,14 @@ +/** + * Spotify Web API Content Narrator object. + * + * Contains information about content narrators. + */ +export interface INarrator { + + + /** + * The narrator name for this content. + */ + name: string; + +} diff --git a/src/types/spotifyplus/owner.ts b/src/types/spotifyplus/owner.ts new file mode 100644 index 0000000..2b87903 --- /dev/null +++ b/src/types/spotifyplus/owner.ts @@ -0,0 +1,59 @@ +import { IExternalUrls } from './external-urls'; +import { IFollowers } from './followers'; + +/** + * Spotify Web API Owner object. + * + * Information about the owner of an object (e.g. playlist, etc). + */ +export interface IOwner { + + + /** + * The name displayed on the user's profile, or null if not available. + * + * Example: `John S` + */ + display_name: string; + + + /** + * Known public external URLs for this user. + */ + external_urls: IExternalUrls; + + + /** + * Information about the followers of the user. + */ + followers: IFollowers; + + + /** + * A link to the Web API endpoint providing full details of the user. + */ + href: string; + + + /** + * The Spotify user ID for the user. + * + * Example: `2up3OPMp9Tb4dAKM2erWXQ` + */ + id: string; + + + /** + * The object type: `user`. + */ + type: string; + + + /** + * The Spotify URI for the user. + * + * Example: `spotify:user:2up3OPMp9Tb4dAKM2erWXQ` + */ + uri: string; + +} \ No newline at end of file diff --git a/src/types/spotifyplus/page-object.ts b/src/types/spotifyplus/page-object.ts new file mode 100644 index 0000000..b242b5b --- /dev/null +++ b/src/types/spotifyplus/page-object.ts @@ -0,0 +1,127 @@ +import { IAlbumSaved } from './album-saved'; +import { IAlbumSimplified } from './album-simplified'; +import { IArtist } from './artist'; +import { IAudiobookSimplified } from './audiobook-simplified'; +import { IChapterSimplified } from './chapter-simplified'; +import { IUserPreset } from './user-preset'; +import { IEpisodeSaved } from './episode-saved'; +import { IEpisodeSimplified } from './episode-simplified'; +import { IPlayHistory } from './play-history'; +import { IPlaylistSimplified } from './playlist-simplified'; +import { IPlaylistTrack } from './playlist-track'; +import { IShowSaved } from './show-saved'; +import { IShowSimplified } from './show-simplified'; +import { ITrackSaved } from './track-saved'; +import { ITrackSimplified } from './track-simplified'; + +/** + * Spotify Web API PageObject object. + * + * This allows for multiple pages of objects to be navigated. + */ +export interface IPageObject { + + + /** + * The cursor to use as key to find the next page of items. + * This value will only be populated when cursor-based paging is used, which is infrrequent. + * The value can be of multiple types: string, integer, etc. + * + * Example: `3jdODvx7rIdq0UGU7BOVR3` + * Example: 1708495520273 + * */ + cursor_after?: unknown; + + + /** + * The cursor to use as key to find the previous page of items. + * This value will only be populated when cursor-based paging is used, which is infrrequent. + * The value can be of multiple types: string, integer, etc. + * + * Example: `3jdODvx7rIdq0UGU7BOVR3` + * Example: 1708495520273 + * */ + cursor_before?: unknown; + + + /** + * A link to the Web API endpoint returning the full result of the request. + * + * Example: `https://api.spotify.com/v1/me/shows?offset=0&limit=20` + * */ + href: string; + + + /** + * True if cursors were returned at some point during the life of this paging object. + * */ + is_cursor: boolean; + + + /** + * Array of objects. + * + * This property will be overrriden by inheriting classes. + */ + items: Array; + + + /** + * Number of objects in the `Items` property array. + * */ + items_count?: number; + // @property + // def ItemsCount(self) -> int: + // """ + // Number of objects in the `Items` property array. + // """ + // if self._Items is not None: + // return len(self._Items) + // return 0 + + + /** + * The maximum number of items in the response (as set in the query or by default). + * + * This property can be modified in case the paging request needs to be adjusted + * based upon overall request limits. + * */ + limit: number; + + + /** + * URL to the next page of items; null if none. + * + * Example: `https://api.spotify.com/v1/me/shows?offset=1&limit=1` + * */ + next: string; + + + /** + * The offset of the items returned (as set in the query or by default). + * + * This property can be modified in case the paging request needs to be adjusted + * based upon overall request limits. + * */ + offset?: number; + + + /** + * URL to the previous page of items; null if none. + * + * Example: `https://api.spotify.com/v1/me/shows?offset=1&limit=1` + * */ + previous: string; + + + /** + * The total number of items available from the Spotify Web API to return. + * + * Note that sometimes the Spotify Web API returns a larger total than the actual number + * of items available. Not sure why this is, but it may not match the `ItemsCount` value. + * */ + total?: number; + +} \ No newline at end of file diff --git a/src/types/spotifyplus/play-history-page.ts b/src/types/spotifyplus/play-history-page.ts new file mode 100644 index 0000000..3d1b38c --- /dev/null +++ b/src/types/spotifyplus/play-history-page.ts @@ -0,0 +1,36 @@ +import { IPageObject } from './page-object'; +import { IPlayHistory } from './play-history'; +import { ITrack } from './track'; + +/** + * Spotify Web API PlayHistoryPage object. + * + * This allows for multiple pages of `PlayHistory` objects to be navigated. + */ +export interface IPlayHistoryPage extends IPageObject { + + + /** + * Array of `IPlayHistory` objects. + */ + 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. + * + * This is a convenience method so one does not have to loop through the `Items` + * array of PlayHistory objects to get the list of tracks. + * + * @returns An array of `ITrack` objects that exist in the collection; otherwise, an empty array. + */ + GetTracks(): Array; + +} diff --git a/src/types/spotifyplus/play-history.ts b/src/types/spotifyplus/play-history.ts new file mode 100644 index 0000000..4d6a5bd --- /dev/null +++ b/src/types/spotifyplus/play-history.ts @@ -0,0 +1,42 @@ +import { IContext } from './context'; +import { ITrack } from './track'; + +/** + * Spotify Web API PlayHistory object. + */ +export interface IPlayHistory { + + + /** + * The context the track was played from. + */ + context: IContext; + + + /** + * The date and time the track was played (in local time). + * Example: `2024-01-25T15:33:17.136Z` + */ + played_at: string; + + + /** + * The `PlayedAt` value in Unix millisecond timestamp format, or null if the `PlayedAt` value is null. + * Example: 1706213826000 + */ + played_at_ms: string; + + + /** + * The track the user listened to. + */ + 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/playlist-page-simplified.ts b/src/types/spotifyplus/playlist-page-simplified.ts new file mode 100644 index 0000000..b7100e0 --- /dev/null +++ b/src/types/spotifyplus/playlist-page-simplified.ts @@ -0,0 +1,43 @@ +import { IPageObject } from './page-object'; +import { IPlaylistSimplified } from './playlist-simplified'; + +/** + * Spotify Web API PlaylistPageSimplified object. + * + * This allows for multiple pages of `PlaylistSimplified` objects to be navigated. + */ +export interface IPlaylistPageSimplified extends IPageObject { + + + /** + * Array of `PlaylistSimplified` objects. + */ + 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. + * + * @param itemId ID of the item to check for. + * @returns True if the itemId exists in the collection; otherwise, False. + */ + ContainsId(itemId: string): boolean; + + + /** + * Gets a list of all items contained in the `Items` list that are owned + * by `spotify:user:spotify` + * + * @returns An array of matching `IPlaylistSimplified` objects; otherwise, an empty array. + */ + GetSpotifyOwnedItems(): Array; + +} diff --git a/src/types/spotifyplus/playlist-page.ts b/src/types/spotifyplus/playlist-page.ts new file mode 100644 index 0000000..d093952 --- /dev/null +++ b/src/types/spotifyplus/playlist-page.ts @@ -0,0 +1,82 @@ +import { IPageObject } from './page-object'; +import { IPlaylistTrack } from './playlist-track'; +import { ITrack } from './track'; + +/** + * Spotify Web API PlaylistPage object. + * + * This allows for multiple pages of `Playlist` objects to be navigated. + */ +export interface IPlaylistPage extends IPageObject { + + + /** + * Array of `PlaylistTrack` objects. + */ + 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. + * + * This is a convenience method so one does not have to loop through the `Items` + * array of PlaylistTrack objects to get the list of tracks. + * + * @returns An array of matching `IPlaylistTrack` objects; otherwise, an empty array. + */ + GetTracks(): Array; + +} + + +/** +* Gets a list of all tracks contained in the underlying `items` list. +* +* This is a convenience method so one does not have to loop through the `items` +* array of IPlaylistTrack objects to get the list of tracks. +* +* @returns An array of `PlaylistTrack` objects that exist in the collection; otherwise, an empty array. +*/ +export function GetPlaylistPageTracks(page: IPlaylistPage | undefined): Array { + + const result = new Array(); + if (page) { + page.items.forEach(item => { + if (item.track.name != null) { + result.push(item.track); + } + }) + } + + return result +} + + +/** +* Gets a list of all tracks contained in the underlying `items` list. +* +* This is a convenience method so one does not have to loop through the `items` +* array of IPlaylistTrack objects to get the list of tracks. +* +* @returns An array of `PlaylistTrack` objects that exist in the collection; otherwise, an empty array. +*/ +export function GetPlaylistPagePlaylistTracks(page: IPlaylistPage | undefined): Array { + + const result = new Array(); + if (page) { + page.items.forEach(item => { + if (item.track.name != null) { + result.push(item); + } + }) + } + + return result +} diff --git a/src/types/spotifyplus/playlist-simplified.ts b/src/types/spotifyplus/playlist-simplified.ts new file mode 100644 index 0000000..e04a262 --- /dev/null +++ b/src/types/spotifyplus/playlist-simplified.ts @@ -0,0 +1,112 @@ +import { IExternalUrls } from './external-urls'; +import { IImageObject } from './image-object'; +import { IOwner } from './owner'; +import { IPlaylistPage } from './playlist-page'; +import { IPlaylistTrackSummary } from './playlist-track-summary'; + +/** + * Spotify Web API PlaylistSimplified object. + */ +export interface IPlaylistSimplified { + + + /** + * True if the owner allows other users to modify the playlist. + */ + collaborative: boolean; + + + /** + * The playlist description. + * Only returned for modified, verified playlists; otherwise null. + */ + description: string; + + + /** + * Known external URLs for this playlist. + */ + external_urls: IExternalUrls; + + + /** + * A link to the Web API endpoint providing full details of the playlist. + */ + href: string; + + + /** + * The Spotify user ID for the playlist. + * Example: `5v5ETK9WFXAnGQ3MRubKuE` + */ + id: string; + + + /** + * Images for the playlist. + * + * The array may be empty or contain up to three images. + * The images are returned by size in descending order. + * Note: If returned, the source URL for the image (url) is temporary and will expire in less than a day. + */ + images: Array; + + + /** + * Image to use for media browser displays. + * + * 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; + + + /** + * The name of the playlist. + */ + name: string; + + + /** + * The user who owns the playlist. + */ + owner: IOwner; + + + /** + * The playlist's public/private status: + * - true: the playlist is public. + * - false: the playlist is private. + * - null: the playlist status is not relevant. + */ + public: boolean | void; + + + /** + * The version identifier for the current playlist. + * + * Can be supplied in other requests to target a specific playlist version. + */ + snapshotId: string; + + + /** + * The tracks summary of the playlist. + */ + tracks: IPlaylistTrackSummary | IPlaylistPage; + + + /** + * The object type: `playlist`. + */ + type: string; + + + /** + * The Spotify URI for the playlist. + * + * Example: `spotify:playlist:5v5ETK9WFXAnGQ3MRubKuE` + */ + uri: string; + +} \ No newline at end of file diff --git a/src/types/spotifyplus/playlist-track-summary.ts b/src/types/spotifyplus/playlist-track-summary.ts new file mode 100644 index 0000000..fcc2e31 --- /dev/null +++ b/src/types/spotifyplus/playlist-track-summary.ts @@ -0,0 +1,16 @@ +/** + * Spotify Web API PlaylistTrackSummary object. + */ +export interface IPlaylistTrackSummary { + + /** + * A link to the Web API endpoint where full details of the playlist's tracks can be retrieved. + * */ + href: string; + + /** + * Number of tracks in the playlist. + * */ + total: number; + +} \ No newline at end of file diff --git a/src/types/spotifyplus/playlist-track.ts b/src/types/spotifyplus/playlist-track.ts new file mode 100644 index 0000000..e0c3892 --- /dev/null +++ b/src/types/spotifyplus/playlist-track.ts @@ -0,0 +1,48 @@ +import { IOwner } from './owner'; +import { ITrack } from './track'; + +/** + * Spotify Web API PlaylistTrack object. + */ +export interface IPlaylistTrack { + + + /** + * The date and time the track or episode was added. + * Timestamps are returned in ISO 8601 format as Coordinated Universal Time (UTC) with a zero + * offset: YYYY-MM-DDTHH:MM:SSZ. If the time is imprecise (for example, the date/time of an + * album release), an additional field indicates the precision; see for example, release_date + * in an album object. + * + * Note: some very old playlists may return null in this field. + */ + added_at: number | undefined; + + + /** + * The Spotify user who added the track or episode. + * + * Note: some very old playlists may return null in this field. + */ + added_by: IOwner | undefined; + + + /** + * The first image url in the underlying track album `Images` list, if images are defined; + * otherwise, null. + */ + image_url?: string | undefined; + + + /** + * Whether this track or episode is a local file (True) or not False). + */ + is_local?: boolean; + + + /** + * Information about the track. + */ + track: ITrack; + +} diff --git a/src/types/spotifyplus/playlist.ts b/src/types/spotifyplus/playlist.ts new file mode 100644 index 0000000..94ebb43 --- /dev/null +++ b/src/types/spotifyplus/playlist.ts @@ -0,0 +1,56 @@ +import { IFollowers } from './followers'; +import { IPlaylistPage } from './playlist-page'; +import { IPlaylistSimplified } from './playlist-simplified'; +import { ITrack } from './track'; + +import { GetPlaylistPageTracks } from './playlist-page'; + +/** + * Spotify Web API Playlist object. + */ +export interface IPlaylist extends IPlaylistSimplified { + + + /** + * Information about the followers of the playlist. + */ + followers: IFollowers; + + + /** + * The tracks of the playlist. + * + * This is a `PlaylistPage` object, meaning only 50 tracks max are listed per request. + */ + tracks: IPlaylistPage; + +} + + +/** + * Gets a list of all tracks contained in the underlying `Tracks.Items` list. + * + * This is a convenience method so one does not have to loop through the `Tracks.Items` + * array of PlaylistPage objects to get the list of tracks. + * + * @returns An array of `PlaylistTrack` objects that exist in the collection; otherwise, an empty array. + */ +export function GetPlaylistTracks(playlist: IPlaylist | undefined): Array { + + //console.log("GetTracks (playlist)\n- pages:\n%s", + // JSON.stringify(page), + //); + + let result = new Array(); + if (playlist) { + if (playlist.tracks) { + result = GetPlaylistPageTracks(playlist.tracks) + } + } + + //console.log("GetTracks (playlist)\n- tracks (result):\n%s", + // JSON.stringify(result), + //); + + return result +} diff --git a/src/types/spotifyplus/restrictions.ts b/src/types/spotifyplus/restrictions.ts new file mode 100644 index 0000000..ca86133 --- /dev/null +++ b/src/types/spotifyplus/restrictions.ts @@ -0,0 +1,21 @@ +/** + * Spotify Web API Content Restrictions object. + * + * Contains information about content restrictions. + */ +export interface IRestrictions { + + + /** + * The reason for the restriction. Supported values: + * - market: The content item is not available in the given market. + * - product: The content item is not available for the user's subscription type. + * - explicit: The content item is explicit and the user's account is set to not play explicit content. + * + * Additional reasons may be added in the future. + * + * Note: If you use this field, make sure that your application safely handles unknown values. + */ + reason: string; + +} diff --git a/src/types/spotifyplus/resume-point.ts b/src/types/spotifyplus/resume-point.ts new file mode 100644 index 0000000..2dcd9ff --- /dev/null +++ b/src/types/spotifyplus/resume-point.ts @@ -0,0 +1,42 @@ +import { formatDateHHMMSSFromMilliseconds } from "../../utils/utils"; + +/** + * Spotify Web API Content ResumePoint object. + * + * Contains information about the user's most recent position in the episode. + */ +export interface IResumePoint { + + /** + * Whether or not the episode has been fully played by the user. + */ + fully_played: boolean; + + + /** + * The user's most recent position in the episode in milliseconds. + */ + resume_position_ms: number; + +} + + +/** + * Gets a user-friendly description of the object. + * + * @returns A string that contains a user-friendly description of the object. + */ +export function GetResumeInfo(item: IResumePoint | undefined): string { + + let result = ""; + if (item) { + if (item.fully_played) { + result = "completed"; + } else if ((item.resume_position_ms || 0) == 0) { + result = "starts at beginning"; + } else { + result = "resumes at " + formatDateHHMMSSFromMilliseconds(item.resume_position_ms || 0); + } + } + return result +} diff --git a/src/types/spotifyplus/show-page-saved.ts b/src/types/spotifyplus/show-page-saved.ts new file mode 100644 index 0000000..c78e2cf --- /dev/null +++ b/src/types/spotifyplus/show-page-saved.ts @@ -0,0 +1,60 @@ +import { IPageObject } from './page-object'; +import { IShow } from './show'; +import { IShowSaved } from './show-saved'; +import { IShowSimplified } from './show-simplified'; + +/** + * Spotify Web API ShowPageSaved object. + * + * This allows for multiple pages of `ShowSaved` objects to be navigated. + */ +export interface IShowPageSaved extends IPageObject { + + + /** + * Array of `IShowSaved` objects. + */ + 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. + * + * This is a convenience method so one does not have to loop through the `Items` + * array of ShowSaved objects to get the list of shows. + * + * @returns An array of `IShow` objects that exist in the collection; otherwise, an empty array. + */ + GetShows(): Array; + +} + + + + +/** + * Gets a list of all shows contained in the underlying `items` list. + * + * This is a convenience method so one does not have to loop through the `items` + * array of IShowSaved objects to get the list of shows. + * + * @returns An array of `IShowSimplified` objects that exist in the collection; + * otherwise, an empty array. + */ +export function GetShows(page: IShowPageSaved | IPageObject | undefined): Array { + + const result = new Array(); + if (page) { + for (const item of ((page as IShowPageSaved).items || [])) { + result.push(item.show); + } + } + return result +} diff --git a/src/types/spotifyplus/show-page-simplified.ts b/src/types/spotifyplus/show-page-simplified.ts new file mode 100644 index 0000000..2869b4a --- /dev/null +++ b/src/types/spotifyplus/show-page-simplified.ts @@ -0,0 +1,34 @@ +import { IShowSimplified } from './show-simplified'; +import { IPageObject } from './page-object'; + +/** + * Spotify Web API SimplifiedShowPage object. + * + * This allows for multiple pages of `IShowSimplified` objects to be navigated. + */ +export interface IShowPageSimplified extends IPageObject { + + + /** + * Array of `IShowSimplified` objects. + */ + 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. + * + * @param itemId ID of the item to check for. + * @returns True if the itemId exists in the collection; otherwise, False. + */ + ContainsId(itemId: string): boolean; + +} diff --git a/src/types/spotifyplus/show-saved.ts b/src/types/spotifyplus/show-saved.ts new file mode 100644 index 0000000..8c33597 --- /dev/null +++ b/src/types/spotifyplus/show-saved.ts @@ -0,0 +1,25 @@ +import { IShowSimplified } from './show-simplified'; + +/** + * Spotify Web API SavedShow object. + */ +export interface IShowSaved { + + + /** + * The date and time the show was saved. + * + * Timestamps are returned in ISO 8601 format as Coordinated Universal Time (UTC) with a zero + * offset: YYYY-MM-DDTHH:MM:SSZ. If the time is imprecise (for example, the date/time of a + * episode release), an additional field indicates the precision; see for example, release_date + * in a episode object. + */ + added_at: number; + + + /** + * Information about the show. + */ + show: IShowSimplified; + +} diff --git a/src/types/spotifyplus/show-simplified.ts b/src/types/spotifyplus/show-simplified.ts new file mode 100644 index 0000000..f11cc4d --- /dev/null +++ b/src/types/spotifyplus/show-simplified.ts @@ -0,0 +1,128 @@ +import { ICopyright } from './copyright'; +import { IExternalUrls } from './external-urls'; +import { IImageObject } from './image-object'; + +/** + * Spotify Web API SimplifiedShow object. + */ +export interface IShowSimplified { + + + /** + * A list of the countries in which the show can be played, identified by their ISO 3166-1 alpha-2 code. + */ + available_markets: Array; + + + /** + * The copyright statements of the show. + */ + copyrights: Array; + + + /** + * A description of the show. + * + * HTML tags are stripped away from this field, use html_description field in case HTML tags are needed. + */ + description: string; + + + /** + * Whether or not the show has explicit content (true = yes it does; false = no it does not OR unknown). + */ + explicit: boolean; + + + /** + * Known external url's for the show. + */ + external_urls: IExternalUrls; + + + /** + * A link to the Web API endpoint providing full details of the show. + * Example: `https://api.spotify.com/v1/shows/3IM0lmZxpFAY7CwMuv9H4g?locale=en-US%2Cen%3Bq%3D0.9` + */ + href: string; + + + /** + * A description of the show. This field may contain HTML tags. + */ + html_description: string; + + + /** + * The Spotify ID for the show. + * Example: `3IM0lmZxpFAY7CwMuv9H4g` + */ + id: string; + + + /** + * The cover art for the show in various sizes, widest first. + */ + images: Array; + + + /** + * The first image url in the `Images` list, if images are defined; + * otherwise, null. + */ + image_url?: string | undefined; + + + /** + * True if all of the shows episodes are hosted outside of Spotify's CDN. + * This field might be null in some cases. + */ + is_externally_hosted: boolean; + + + /** + * A list of the languages used in the show, identified by their ISO 639-1 code. + * Example: `[fr,en]` + */ + languages: Array; + + + /** + * The media type of the show. + * Example: `audio` + */ + media_type: string; + + + /** + * The name of the show. + */ + name: string; + + + /** + * The publisher of the show. + */ + publisher: string; + + + /** + * The total number of episodes in the show. + */ + total_episodes: number; + + + /** + * The object type: `show`. + */ + type: string; + + + /** + * The Spotify URI for the show. + * + * Example: `spotify:show:3IM0lmZxpFAY7CwMuv9H4g` + */ + uri: string; + +} diff --git a/src/types/spotifyplus/show.ts b/src/types/spotifyplus/show.ts new file mode 100644 index 0000000..03fe6a9 --- /dev/null +++ b/src/types/spotifyplus/show.ts @@ -0,0 +1,17 @@ +import { IEpisodePageSimplified } from './episode-page-simplified'; +import { IShowSimplified } from './show-simplified'; + +/** + * Spotify Web API Show object. + */ +export interface IShow extends IShowSimplified { + + + /** + * The episodes of the show. + * + * This is a `IEpisodePageSimplified` object. + */ + episodes: IEpisodePageSimplified; + +} diff --git a/src/types/spotifyplus/spotify-basic-object.ts b/src/types/spotifyplus/spotify-basic-object.ts new file mode 100644 index 0000000..17301c7 --- /dev/null +++ b/src/types/spotifyplus/spotify-basic-object.ts @@ -0,0 +1,26 @@ +/** + * Spotify Web API basic object. + */ +export interface ISpotifyBasicObject { + + /** + * The Spotify ID for the object. + * Example: `2CIMQHirSU0MQqyYHq0eOx` + */ + id: string; + + + /** + * The object type (e.g. `artist`, `album`, etc). + */ + type: string; + + + /** + * The Spotify URI for the object. + * + * Example: `spotify:artist:2CIMQHirSU0MQqyYHq0eOx` + */ + uri: string; + +} \ No newline at end of file diff --git a/src/types/spotifyplus/spotify-connect-device.ts b/src/types/spotifyplus/spotify-connect-device.ts new file mode 100644 index 0000000..26b91dc --- /dev/null +++ b/src/types/spotifyplus/spotify-connect-device.ts @@ -0,0 +1,56 @@ +import { IZeroconfDiscoveryResult } from './zeroconf-discovery-result'; +import { IZeroconfGetInfo } from './zeroconf-get-info'; + +/** + * Spotify Connect Device object. + * + * Information about the Spotify Connect device, which is a combination of the + * `IZeroconfDiscoveryResult` and `IZeroconfGetInfo` classes. + */ +export interface ISpotifyConnectDevice { + + /** + * Information about the Zeroconf entry for a SpotifyConnect device as found by Zeroconf (mDNS). + */ + DiscoveryResult: IZeroconfDiscoveryResult; + + + /** + * Spotify Zeroconf API GetInfo response object. + */ + DeviceInfo: IZeroconfGetInfo; + + + /** + * Spotify Connect device id value (e.g. "30fbc80e35598f3c242f2120413c943dfd9715fe"). + */ + Id: string; + + + /** + * Image to use for media browser displays. + * + * 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; + + + /** + * Spotify Connect device name value (e.g. "Bose-ST10-1"). + */ + Name: string; + + + /** + * Spotify Connect device name and id value (e.g. '"Bose-ST10-1" (30fbc80e35598f3c242f2120413c943dfd9715fe)'). + */ + Title: string; + + + /** + * True if the device was re-connected, after being inactive or disconnected. + */ + WasReConnected: boolean; + +} diff --git a/src/types/spotifyplus/spotify-connect-devices.ts b/src/types/spotifyplus/spotify-connect-devices.ts new file mode 100644 index 0000000..51ff7e0 --- /dev/null +++ b/src/types/spotifyplus/spotify-connect-devices.ts @@ -0,0 +1,196 @@ +import { ISpotifyConnectDevice } from './spotify-connect-device'; + +/** + * Spotify Connect Devices collection. + */ +export interface ISpotifyConnectDevices { + + /** + * Number of seconds between the current date time and the `DateLastRefreshed` property value. + */ + AgeLastRefreshed: number; + + + /** + * Date and time the device list was last refreshed, in unix epoch format (e.g. 1669123919.331225). + */ + DateLastRefreshed: number; + + + /** + * Array of `ISpotifyConnectDevice` objects. + */ + Items: Array; + + + /** + * Number of objects in the `Items` property array. + */ + 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; + +} + + +// def ContainsDeviceId(self, value:str) -> bool: +// """ +// Returns True if the `Items` collection contains the specified device id value; +// otherwise, False. + +// Alias entries (if any) are also compared. +// """ +// scDevice:SpotifyConnectDevice = self.GetDeviceById(value) +// if scDevice is not None: +// return True +// return False + + +// def ContainsDeviceName(self, value:str) -> bool: +// """ +// Returns True if the `Items` collection contains the specified device name value; +// otherwise, False. + +// Alias entries (if any) are also compared. +// """ +// scDevice:SpotifyConnectDevice = self.GetDeviceByName(value) +// if scDevice is not None: +// return True +// return False + + +// def ContainsZeroconfEndpointGetInformation(self, value:str) -> bool: +// """ +// Returns True if the `Items` collection contains the specified Zeroconf getInfo Endpoint url value; +// otherwise, False. +// """ +// result:bool = False +// if value is None: +// return result + +// # convert case for comparison. +// value = value.lower() + +// # process all discovered devices. +// scDevice:SpotifyConnectDevice +// for scDevice in self._Items: +// if (scDevice.DiscoveryResult.ZeroconfApiEndpointGetInformation.lower() == value): +// result = True +// break +// return result + + +// def GetDeviceById(self, value:str) -> bool: +// """ +// Returns a `SpotifyConnectDevice` instance if the `Items` collection contains the specified +// device id value; otherwise, None. + +// Alias entries (if any) are also compared. +// """ +// result:SpotifyConnectDevice = None +// if value is None: +// return result + +// # convert case for comparison. +// value = value.lower() + +// # process all discovered devices. +// scDevice:SpotifyConnectDevice +// for scDevice in self._Items: +// if (scDevice.DeviceInfo.HasAliases): +// scAlias:ZeroconfGetInfoAlias +// for scAlias in scDevice.DeviceInfo.Aliases: +// if (scAlias.Id.lower() == value): +// result = scDevice +// break +// if result: +// break +// else: +// if (scDevice.DeviceInfo.DeviceId.lower() == value): +// result = scDevice +// break +// return result + + +// def GetDeviceByName(self, value:str) -> bool: +// """ +// Returns a `SpotifyConnectDevice` instance if the `Items` collection contains the specified +// device name value; otherwise, None. + +// Alias entries (if any) are also compared. +// """ +// result:SpotifyConnectDevice = None +// if value is None: +// return result + +// # convert case for comparison. +// value = value.lower() + +// # process all discovered devices. +// scDevice:SpotifyConnectDevice +// for scDevice in self._Items: +// if (scDevice.DeviceInfo.HasAliases): +// scAlias:ZeroconfGetInfoAlias +// for scAlias in scDevice.DeviceInfo.Aliases: +// if (scAlias.Name.lower() == value): +// result = scDevice +// break +// if result is not None: +// break +// else: +// if (scDevice.DeviceInfo.RemoteName.lower() == value): +// result = scDevice +// break +// return result + + +// def GetDeviceList(self) -> list[Device]: +// """ +// Returns a list of `Device` objects that can be used to build a selection list +// of available devices. + +// Note that the `Device` object has the following properties populated: +// Id, Name, IsActive, Type. +// """ +// result:list[Device] = [] + +// # process all discovered devices. +// scDevice:SpotifyConnectDevice +// for scDevice in self._Items: + +// # map device information details. +// info:ZeroconfGetInfo = scDevice.DeviceInfo + +// # create new mock device. +// device = Device() +// device.Type = info.DeviceType + +// # are aliases being used (RemoteName is null if so)? +// if info.RemoteName is None: + +// # if aliases are defined, then use the alias details. +// infoAlias:ZeroconfGetInfoAlias +// for infoAlias in info.Aliases: +// device.Id = infoAlias.Id +// device.Name = infoAlias.Name + +// else: + +// # if no aliases then use the remote name and id. +// device.Id = info.DeviceId +// device.Name = info.RemoteName + +// # append device to results. +// result.append(device) + +// # sort items on Name property, ascending order. +// if len(result) > 0: +// result.sort(key=lambda x: (x.Name or "").lower(), reverse=False) + +// return result diff --git a/src/types/spotifyplus/track-page-saved.ts b/src/types/spotifyplus/track-page-saved.ts new file mode 100644 index 0000000..56e4073 --- /dev/null +++ b/src/types/spotifyplus/track-page-saved.ts @@ -0,0 +1,56 @@ +import { IPageObject } from './page-object'; +import { ITrackSaved } from './track-saved'; +import { ITrack } from './track'; + +/** + * Spotify Web API TrackPageSaved object. + * + * This allows for multiple pages of `TrackSaved` objects to be navigated. + */ +export interface ITrackPageSaved extends IPageObject { + + + /** + * Array of `TrackSaved` objects. + */ + 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. + * + * This is a convenience method so one does not have to loop through the `items` + * array of ITrackSaved objects to get the list of tracks. + * + * @returns An array of `ITrack` objects that exist in the collection; otherwise, an empty array. + */ + GetTracks(): Array; + +} + + +/** +* Gets a list of all tracks contained in the underlying `items` list. +* +* This is a convenience method so one does not have to loop through the `items` +* array of ITrackSaved objects to get the list of albums. +* +* @returns An array of `ITrack` objects that exist in the collection; otherwise, an empty array. +*/ +export function GetTracks(page: ITrackPageSaved | IPageObject | undefined): Array { + + const result = new Array(); + if (page) { + for (const item of ((page as ITrackPageSaved).items || [])) { + result.push(item.track); + } + } + return result +} diff --git a/src/types/spotifyplus/track-page-simplified.ts b/src/types/spotifyplus/track-page-simplified.ts new file mode 100644 index 0000000..2a4b427 --- /dev/null +++ b/src/types/spotifyplus/track-page-simplified.ts @@ -0,0 +1,65 @@ +import { IPageObject } from './page-object'; +import { ITrackSimplified } from './track-simplified'; + +/** + * Spotify Web API SimplifiedTrackPage object. + * + * This allows for multiple pages of `TrackSimplified` objects to be navigated. + */ +export interface ITrackPageSimplified extends IPageObject { + + + /** + * Array of `TrackSimplified` objects. + */ + items: Array; + +} + + +/** +* Gets a list of all tracks contained in the underlying `items` list. +* +* This is a convenience method so one does not have to loop through the `items` +* array of ITrackPageSimplified objects to get the list of tracks. +* +* @returns An array of `ITrackSimplified` objects that exist in the collection; otherwise, an empty array. +*/ +export function GetTracks(page: ITrackPageSimplified | undefined): Array { + + //console.log("GetTracks (track-page-simplified)\n- pages:\n%s", + // JSON.stringify(page), + //); + + const result = new Array(); + if (page) { + page.items.forEach(item => { + result.push(item); + }) + } + + //console.log("GetTracks (track-page-simplified)\n- tracks (result):\n%s", + // JSON.stringify(result), + //); + + return result +} + + + +// def ContainsId(self, itemId:str=False) -> bool: +// """ +// Checks the `Items` collection to see if an item already exists with the +// specified Id value. + +// Returns True if the itemId exists in the collection; otherwise, False. +// """ +// result:bool = False + +// item:TrackSimplified +// for item in self._Items: +// if item.Id == itemId: +// result = True +// break + +// return result diff --git a/src/types/spotifyplus/track-page.ts b/src/types/spotifyplus/track-page.ts new file mode 100644 index 0000000..171aed3 --- /dev/null +++ b/src/types/spotifyplus/track-page.ts @@ -0,0 +1,24 @@ +import { ITrack } from './track'; +import { IPageObject } from './page-object'; + +/** + * Spotify Web API TrackPage object. + * + * This allows for multiple pages of `Track` objects to be navigated. + */ +export interface ITrackPage extends IPageObject { + + + /** + * Array of `Track` objects. + */ + 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/track-saved.ts b/src/types/spotifyplus/track-saved.ts new file mode 100644 index 0000000..3e7373a --- /dev/null +++ b/src/types/spotifyplus/track-saved.ts @@ -0,0 +1,24 @@ +import { ITrack } from './track'; + +/** + * Spotify Web API SavedTrack object. + */ +export interface ITrackSaved { + + + /** + * The date and time the track was saved. + * Timestamps are returned in ISO 8601 format as Coordinated Universal Time (UTC) with a zero + * offset: YYYY-MM-DDTHH:MM:SSZ. If the time is imprecise (for example, the date/time of an + * track release), an additional field indicates the precision; see for example, release_date + * in an track object. + */ + added_at: number; + + + /** + * Information about the track. + */ + track: ITrack; + +} diff --git a/src/types/spotifyplus/track-simplified.ts b/src/types/spotifyplus/track-simplified.ts new file mode 100644 index 0000000..2385ae9 --- /dev/null +++ b/src/types/spotifyplus/track-simplified.ts @@ -0,0 +1,130 @@ +import { IArtistSimplified } from './artist-simplified'; +import { IExternalUrls } from './external-urls'; +import { IRestrictions } from './restrictions'; + +/** + * Spotify Web API SimplifiedTrack object. + */ +export interface ITrackSimplified { + + /** + * A list of artists who performed the track. + */ + artists: Array; + + + /** + * A list of the countries in which the track can be played, identified by their ISO 3166-1 alpha-2 code. + */ + available_markets: Array; + + + /** + * The disc number (usually 1 unless the album consists of more than one disc). + */ + disc_number: number; + + + /** + * The track length in milliseconds. + */ + duration_ms: number; + + + /** + * Whether or not the track has explicit lyrics (true = yes it does; false = no it does not OR unknown). + */ + explicit: boolean; + + + /** + * Known external URLs for this track. + */ + external_urls: IExternalUrls; + + + /** + * A link to the Web API endpoint providing full details of the track. + */ + href: string; + + + /** + * The Spotify ID for the track. + * Example: `1301WleyT98MSxVHPZCA6M` + */ + id: string; + + + /** + * Always returns null, as tracks currently do not support images. + * + * Added for compatibility with other objects. + */ + image_url?: string | undefined; + + + /** + * Whether or not the track is from a local file. + */ + is_local: boolean; + + + /** + * Part of the response when Track Relinking is applied. + * If true, the track is playable in the given market. Otherwise false. + */ + is_playable: boolean; + + + /** + * Part of the response when Track Relinking is applied, and the requested track has been replaced + * with different track. The track in the LinkedFrom object contains information about the originally + * requested track. + */ + linked_from: object; + + + /** + * The name of the track. + */ + name: string; + + + /** + * A link to a 30 second preview (MP3 format) of the track. Can be null. + * + * Important policy note: + * - Spotify Audio preview clips can not be a standalone service. + */ + preview_url: string; + + + /** + * Included in the response when a content restriction is applied. + */ + restrictions: IRestrictions; + + + /** + * The number of the track. + * + * If an album has several discs, the track number is the number on the specified disc. + */ + track_number: number; + + + /** + * The object type: `track`. + */ + type: string; + + + /** + * The Spotify URI for the track. + * + * Example: `spotify:track:1301WleyT98MSxVHPZCA6M` + */ + uri: string; + +} diff --git a/src/types/spotifyplus/track.ts b/src/types/spotifyplus/track.ts new file mode 100644 index 0000000..f92e18a --- /dev/null +++ b/src/types/spotifyplus/track.ts @@ -0,0 +1,49 @@ +import { IAlbum } from './album'; +import { IExternalUrls } from './external-urls'; +import { ITrackSimplified } from './track-simplified'; + +/** + * Spotify Web API Track object. + */ +export interface ITrack extends ITrackSimplified { + + + /** + * The album on which the track appears. + * + * The album object includes a link in href to full information about the album. + */ + album: IAlbum; + + + /** + * Known external id's for the album. + */ + external_ids: IExternalUrls; + + + /** + * The first image url in the album `Images` list, if images are defined; + * otherwise, null. + */ + image_url?: string | undefined; + + + /** + * The popularity of the track. + * + * The value will be between 0 and 100, with 100 being the most popular. + */ + popularity: number; + +} + +// @property +// def ImageUrl(self) -> str: +// """ +// Gets the first image url in the album `Images` list, if images are defined; +// otherwise, null. +// """ +// if self._Album is not None: +// return self._Album.ImageUrl +// return None diff --git a/src/types/spotifyplus/user-preset.ts b/src/types/spotifyplus/user-preset.ts new file mode 100644 index 0000000..7d45d9e --- /dev/null +++ b/src/types/spotifyplus/user-preset.ts @@ -0,0 +1,42 @@ +/** + * User preset item configuration object. + */ + +export interface IUserPreset { + + /** + * Image url that will be displayed when icons are used for browsing. + */ + image_url: string; + + + /** + * Friendly name to display that represents the media item. + */ + name: string | null; + + + /** + * Origin location of the content item (e.g. `config`, `file`). + */ + origin: string | null; + + + /** + * Friendly subtitle to display that represents the media item. + */ + subtitle: string | null; + + + /** + * Item type (e.g. "playlist", "album", "artist", etc). + */ + type: string | null; + + + /** + * Spotify URI value that uniquely identifies the item (e.g. spotify:album:xxxxxx, etc) + */ + uri: string | null; + +} diff --git a/src/types/spotifyplus/zeroconf-discovery-result.ts b/src/types/spotifyplus/zeroconf-discovery-result.ts new file mode 100644 index 0000000..cfe081e --- /dev/null +++ b/src/types/spotifyplus/zeroconf-discovery-result.ts @@ -0,0 +1,190 @@ +import { IZeroconfProperty } from './zeroconf-property'; + +/** + * Zeroconf Discovery Result object. + * + * Information about the Zeroconf entry for a SpotifyConnect device as found by Zeroconf (mDNS). + */ +export interface IZeroconfDiscoveryResult { + + + /** + * Device name (e.g. "Bose-ST10-1"). + */ + DeviceName: string; + + + /** + * Domain on which the service is located, which should match the one passed in during the query (e.g. "local."). + */ + Domain: string; + + + /** + * IP address at which the host can be reached (e.g. "192.168.1.81"). + * + * This value may also contain a DNS alias, if no IP addresses were discovered + * for the device. This is very rare, but possible. + */ + HostIpAddress: string; + + + /** + * IP address(es) at which the host can be reached (e.g. ["192.168.1.81", "172.30.32.1"]). + * + * Note that this value can contain multiple addresses. + */ + HostIpAddresses: Array; + + + /** + * Port number (as an integer) for the service on the host (e.g. 8080). + */ + HostIpPort: string; + + + /** + * Host Time-To-Live value (as an integer) for the service on the host (e.g. 1200). + */ + HostTTL: number; + + + /** + * Returns True if the device is a dynamic device; + * otherwise, False. + * + * Dynamic devices are Spotify Connect devices that are not found in Zeroconf discovery + * process, but still exist in the player device list. These are usually Spotify Connect + * web or mobile players with temporary device id's. + */ + IsDynamicDevice: boolean; + + + /** + * Service key (e.g. "bose-st10-2._spotify-connect._tcp.local."). + */ + Key: string; + + + /** + * Service name (e.g. "Bose-ST10-2._spotify-connect._tcp.local."). + */ + Name: string; + + + /** + * Priority value (as an integer) for the service on the host (e.g. 0). + */ + Priority: number; + + + /** + * Other Time-To-Live value (as an integer) for the service on the host (e.g. 5400). + */ + OtherTTL: number; + + + /** + * Discovered properties. + */ + Properties: Array; + + + /** + * Server name (e.g. "Bose-SM2-341513fbeeae.local."). + */ + Server: string; + + + /** + * Server key (e.g. "bose-sm2-341513fbeeae.local."). + */ + ServerKey: string; + + + /** + * Service type, which should match the one passed in during the query name (e.g. "_spotify-connect._tcp."). + */ + ServiceType: string; + + + /** + * Weight value (as an integer) for the service on the host (e.g. 0). + */ + Weight: number; + + + // ***************************************************************************** + // The following are Spotify Connect specific properties. + // ***************************************************************************** + + /** + * Spotify Connect CPath property value (e.g. "/zc"). + */ + SpotifyConnectCPath: string; + + + /** + * True if the device is in the Spotify Player active device list; otherwise, False. + */ + SpotifyConnectIsInDeviceList: boolean; + + + /** + * Spotify Connect Version property value (e.g. null, "1.0"). + */ + SpotifyConnectVersion: string; + + + /** + * Zeroconf API endpoint to add a user to a Spotify Connect device (e.g. "http://192.168.1.81:8200/zc?action=addUser&version=2.10.0"). + */ + ZeroconfApiEndpointAddUser: string; + + + /** + * Zeroconf API endpoint to retrieve device information for a Spotify Connect device (e.g. "http://192.168.1.81:8200/zc?action=getInfo&version=2.10.0"). + */ + ZeroconfApiEndpointGetInformation: string; + + + /** + * Zeroconf API endpoint to reset users (e.g. Logoff) currently active on a Spotify Connect device (e.g. "http://192.168.1.81:8200/zc?action=resetUsers&version=2.10.0"). + */ + ZeroconfApiEndpointResetUsers: string; + + +} + + +//# external package imports. +//from zeroconf import ServiceInfo + +// @property +// def ServiceInfo(self) -> ServiceInfo: +// """ +// Zeroconf Service info object. +// """ +// return self._ServiceInfo + + +// def GetEndpointUrl(self, action:str) -> str: +// """ +// Gets a Spotify Zeroconf API endpoint url for the specified action key. + +// Args: +// action (str): +// Spotify Zeroconf endpoint action to formulate (e.g. 'getInfo', 'addUser', 'resetUsers', etc). + +// Returns: +// A string containing the endpoint url for the specified action key. +// """ +// return "http://{ip}:{port}{cpath}?action={action}&version={version}".format( +// ip=self.HostIpAddress, +// port=self.HostIpPort, +// cpath=self.SpotifyConnectCPath, +// action=action, +// version=self.SpotifyConnectVersion or '' +// ) + + diff --git a/src/types/spotifyplus/zeroconf-get-info-alias.ts b/src/types/spotifyplus/zeroconf-get-info-alias.ts new file mode 100644 index 0000000..eedfc76 --- /dev/null +++ b/src/types/spotifyplus/zeroconf-get-info-alias.ts @@ -0,0 +1,30 @@ +/** + * Spotify Zeroconf API GetInfo Alias object. + */ +export interface IZeroconfGetInfoAlias { + + + /** + * Unique identifier of the alias. + */ + Id: string; + + + /** + * True if the alias is a group; otherwise, False. + */ + IsGroup: boolean; + + + /** + * Display name of the alias. + */ + Name: string; + + + /** + * Alias name and id value (e.g. '"Bose-ST10-1" (30fbc80e35598f3c242f2120413c943dfd9715fe)'). + */ + Title: string; + +} diff --git a/src/types/spotifyplus/zeroconf-get-info-drm-media-format.ts b/src/types/spotifyplus/zeroconf-get-info-drm-media-format.ts new file mode 100644 index 0000000..b3b1ad9 --- /dev/null +++ b/src/types/spotifyplus/zeroconf-get-info-drm-media-format.ts @@ -0,0 +1,24 @@ +/** + * Spotify Zeroconf API GetInfo DRM Media Format object. + */ +export interface IZeroconfGetInfoDrmMediaFormat { + + + /** + * DRM format which the integration supports (SpDrmFormat). + * + * kSpDrmFormatUnknown Unknown DRM. + * kSpDrmFormatUnencrypted No DRM, unencrypted. + * kSpDrmFormatFairPlay FairPlay. + * kSpDrmFormatWidevine Widevine. + * kSpDrmFormatPlayReady PlayReady. + */ + Drm: number; + + + /** + * Supported media formats for a DRM. + */ + Formats: number; + +} diff --git a/src/types/spotifyplus/zeroconf-get-info.ts b/src/types/spotifyplus/zeroconf-get-info.ts new file mode 100644 index 0000000..3bcc396 --- /dev/null +++ b/src/types/spotifyplus/zeroconf-get-info.ts @@ -0,0 +1,259 @@ +import { IZeroconfGetInfoAlias } from './zeroconf-get-info-alias'; +import { IZeroconfGetInfoDrmMediaFormat } from './zeroconf-get-info-drm-media-format'; +import { IZeroconfResponse } from './zeroconf-response'; + +/** + * Spotify Zeroconf API GetInfo response object. + */ +export interface IZeroconfGetInfo extends IZeroconfResponse { + + + /** + * ? (e.g. "DONTCARE"). + */ + AccountReq: string; + + + /** + * Canonical username of the logged in user (e.g. "31l77y2123456789012345678901"). + * + * This value will be an empty string if there is no user logged into the device. + */ + ActiveUser: string; + + + /** + * Device alias information, IF the device supports aliases. + * + * Using ZeroConf, it is possible to announce multiple "virtual devices" from a device. + * This allows the eSDK device to expose, for instance, multiroom zones as ZeroConf devices. + * + * Device aliases will show up as separate devices in the Spotify app. + * + * A maximum of 8 aliases are supported (SP_MAX_DEVICE_ALIASES). + * + * Please refer to the `RemoteName` property for more information. + */ + Aliases: Array; + + + /** + * The SpZeroConfVars availability field returned by SpZeroConfGetVars. + * + * The following are values that I have encountered thus far: + * - "" - Device is available and ready for use. + * - "UNAVAILABLE" - Device is unavailable, and should probably be rebooted. + * - "NOT-LOADED" - Spotify SDK / API is not loaded. + * + * The maximum length of the availability string (SP_MAX_AVAILABILITY_LENGTH) + * is 15 characters (not counting terminating NULL). + */ + Availability: string; + + + /** + * A UTF-8-encoded brand name of the hardware device, for hardware integrations (e.g. "Bose", "Onkyo", etc). + * + * The maximum length of the brand display name (SP_MAX_BRAND_NAME_LENGTH) + * is 32 characters (not counting terminating NULL). + */ + BrandDisplayName: string; + + + /** + * Client id of the application (e.g. "79ebcb219e8e4e123456789000123456"). + * + * The maximum length of the client ID value (SP_MAX_CLIENT_ID_LENGTH) + * is 32 characters (not counting terminating NULL). + */ + ClientId: string; + + + /** + * Unique device ID used for ZeroConf logins (e.g. "30fbc80e35598f3c242f2120413c943dfd9715fe"). + * + * The maximum length of the device ID value used for ZeroConf logins (SP_MAX_DEVICE_ID_LENGTH) + * is 64 characters (not counting terminating NULL). + */ + DeviceId: string; + + + /** + * Type of the device (e.g. "SPEAKER", "AVR", etc). + * + * Can be any of the following `SpDeviceType` devices: + * - Computer Laptop or desktop computer device. + * - Tablet Tablet PC device. + * - Smartphone Smartphone device. + * - Speaker Speaker device. + * - TV Television device. + * - AVR Audio/Video receiver device. + * - STB Set-Top Box device. + * - AudioDongle Audio dongle device. + * - GameConsole Game console device. + * - CastVideo Chromecast Video. + * - CastAudio Chromecast Audio. + * - Automobile Automobile. + * - Smartwatch Smartwatch. + * - Chromebook Chromebook. + * + * The maximum length of the device type string (SP_MAX_DEVICE_TYPE_LENGTH) + * is 15 characters (not counting terminating NULL). + */ + DeviceType: string; + + + /** + * The SpZeroConfVars group_status field returned by SpZeroConfGetVars (e.g. "NONE"). + * + * The maximum length of the group status string (SP_MAX_GROUP_STATUS_LENGTH) + * is 15 characters (not counting terminating NULL). + */ + GroupStatus: string; + + + /** + * Returns True if the device has an active user account specified; + * otherwise, False. + */ + HasActiveUser: boolean; + + + /** + * Returns True if the device has alias entries defined; + * otherwise, False. + */ + HasAliases: boolean; + + + /** + * Returns True if the device is available; otherwise, False. + * + * Determination is made based upon the `Availability` property value. + */ + IsAvailable: boolean; + + + /** + * Returns True if the device is a 'Sonos' branded device; otherwise, False. + * + * Determination is made based upon the `BrandDisplayName` property value. + */ + IsBrandSonos: boolean; + + + /** + * Client library version that processed the Zeroconf action (e.g. "3.88.29-gc4d4bb01"). + */ + LibraryVersion: string; + + + /** + * A UTF-8-encoded model name of the hardware device, for hardware integrations (e.g. "Soundtouch"). + * + * The maximum length of the model display name (SP_MAX_MODEL_NAME_LENGTH) + * is 30 characters (not counting terminating NULL). + */ + ModelDisplayName: string; + + + /** + * An integer enumerating the product for this partner (e.g. 12345). + */ + ProductId: string; + + + /** + * Public key used in ZeroConf logins (e.g. "G+ZM4irhc..."). + * + * The maximum length of the public key used in ZeroConf logins (SP_MAX_PUBLIC_KEY_LENGTH) + * is 149 characters (not counting terminating NULL). + */ + PublicKey: string; + + + /** + * Name to be displayed for the device (e.g. "BOSE-ST10-1"). + * + * This value will be null if the response is from a device using device aliases, + * as the displayed name for respective alias is defined in the aliases field. + * + * Please refer to the `Aliases` property for more information. + */ + RemoteName: string; + + + /** + * The SpZeroConfVars resolver_version field returned by SpZeroConfGetVars (e.g. "0"). + */ + ResolverVersion: string; + + + /** + * OAuth scope requested when authenticating with the Spotify backend (e.g. "streaming"). + * + * The maximum length of the token type string (SP_MAX_SCOPE_LENGTH) + * is 64 characters (not counting terminating NULL). + */ + Scope: string; + + + /** + * Bitmasked integer representing list of device capabilities (e.g. 0). + */ + SupportedCapabilities: number; + + + /** + * The SpZeroConfVars supported_drm_media_formats field returned by SpZeroConfGetVars (e.g. []). + * + * A maximum of 8 formats are supported (SP_MAX_SUPPORTED_FORMATS). + */ + SupportedDrmMediaFormats: Array; + + + /** + * Token type provided by the client: + * - "accesstoken" Access token. + * - "authorization_code" OAuth Authorization Code token. + * - "default" Default access token. + * + * The maximum length of the token type string (SP_MAX_TOKEN_TYPE_LENGTH) + * is 30 characters (not counting terminating NULL). + */ + TokenType: string; + + + /** + * ZeroConf API version number (e.g. "2.10.0"). + * + * The maximum length of the library version string (SP_MAX_VERSION_LENGTH) + * is 30 characters (not counting terminating NULL). + */ + Version: string; + + + /** + * Indicates if the device supports voice commands (e.g. "YES"). + */ + VoiceSupport: string; + + + // non-Spotify properties. + + /** + * Returns True if this device is the currently active device; + * otherwise, False. + */ + IsActiveDevice: boolean; + + + /** + * Returns the status of active device list verification: + * * None - device list verification has not been performed for this device. + * * True - device is a member of the active device list. + * * False - device is NOT a member of the active device list. + */ + IsInDeviceList: boolean; + +} diff --git a/src/types/spotifyplus/zeroconf-property.ts b/src/types/spotifyplus/zeroconf-property.ts new file mode 100644 index 0000000..b56966a --- /dev/null +++ b/src/types/spotifyplus/zeroconf-property.ts @@ -0,0 +1,19 @@ +/** + * Zeroconf Property object. + * + * Information about a Zeroconf property. + */ +export interface IZeroconfProperty { + + /** + * Property name. + */ + Name: string; + + + /** + * Property value. + */ + Value: string; + +} diff --git a/src/types/spotifyplus/zeroconf-response.ts b/src/types/spotifyplus/zeroconf-response.ts new file mode 100644 index 0000000..89f583e --- /dev/null +++ b/src/types/spotifyplus/zeroconf-response.ts @@ -0,0 +1,29 @@ +/** + * Spotify Zeroconf API basic response object. + */ +export interface IZeroconfResponse { + + /** + * Response source string (e.g. ""). + */ + ResponseSource: string; + + + /** + * The last error code returned by a Spotify API call or the SpCallbackError() callback (e.g. 0, -119, etc). + */ + SpotifyError: number; + + + /** + * A code indicating the result of the operation (e.g. 101, 402, etc). + */ + Status: number; + + + /** + * The string describing the status code (e.g. "OK", "ERROR-SPOTIFY-ERROR", etc). + */ + StatusString: string; + +} diff --git a/src/utils/media-browser-utils.ts b/src/utils/media-browser-utils.ts new file mode 100644 index 0000000..2db4daf --- /dev/null +++ b/src/utils/media-browser-utils.ts @@ -0,0 +1,461 @@ +// 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'; + +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=='; + + +/** + * Removes all special characters from a string, so that it can be used + * for comparison operations. + * + * @param str String value to remove special characters from. + * @returns The `str` value without special characters. + */ +export function removeSpecialChars(str: string) { + let value = str.replace(/[^a-zA-Z ]/g, ''); + if (value) + value = value.trim(); + return value; +} + + +/** + * Searches the configuration custom ImageUrl's collection for a matching title. + * The item imageUrl is returned if a match is found; otherwise, undefined. + * + * @param collection Configuration customImageUrls collection to search. + * @param title Title to search for in the collection. + */ +export function getCustomImageUrl(collection: CustomImageUrls | undefined, title: string) { + + // search collection for matching title and return the imageUrl. + // remove any special characters from the title before comparing. + // note that we already removed special characters from the collection + // in the setConfig() method when the card configuration was loaded. + for (const itemTitle in collection) { + if (itemTitle === removeSpecialChars(title)) { + return collection[itemTitle]; + } + } + + // if not found then return undefined. + return undefined; +} + + +/** + * Gets the image url that will be displayed in the media browser for items that contain + * an image_url attribute. + * + * 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). + * - configuration `customImageUrls` `default` value (if one exists). + * - hard-coded `default image` data if all else fails. + * + * 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) { + + // 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 imageUrl is a home assistant brands logo, then use the 'icon.png' image. + if (imageUrl?.match(/https:\/\/brands\.home-assistant\.io\/.+\/logo.png/)) { + imageUrl = imageUrl?.replace('logo.png', 'icon.png'); + } + + // return imageUrl to caller. + return imageUrl || ''; +} + + +/** + * Converts an mdiIcon path to a url that can be used as a CSS `background-image url()` value. + * + * @param mdi_icon mdi icon to convert. + */ +export function getMdiIconImageUrl(mdi_icon: string): string { + + const mdiImageUrl = '\'data:image/svg+xml;utf-8,\''; + return mdiImageUrl + +} + + +/** + * Returns true if ANY of the items have an image_url specified; + * otherwise, false indicates no image_url's are present in the list. + * + * @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[]) { + + 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. + * @section Current section that is active. + * @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) { + + // 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 (section == Section.ALBUM_FAVORITES) { + const itemInfo = (item as IAlbumSimplified); + mbi_info.subtitle = itemInfo.artists[0]?.name || (itemInfo.total_tracks || 0 + " tracks") || item.type; + } else if (section == Section.ARTIST_FAVORITES) { + const itemInfo = (item as IArtist); + mbi_info.subtitle = ((itemInfo.followers.total || 0) + " followers") || item.type; + } else if (section == Section.AUDIOBOOK_FAVORITES) { + const itemInfo = (item as IAudiobookSimplified); + mbi_info.subtitle = GetAudiobookAuthors(itemInfo, ", ") || item.type; + } else if (section == 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 (section == Section.EPISODE_FAVORITES) { + // spotify search episode returns an IEpisodeSimplified, so show property will by 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) { + const itemInfo = (item as IPlaylistSimplified); + mbi_info.subtitle = (itemInfo.tracks.total || 0) + " tracks"; + } else if (section == Section.SHOW_FAVORITES) { + const itemInfo = (item as IShowSimplified); + mbi_info.subtitle = (itemInfo.total_episodes || 0) + " episodes"; + } else if (section == Section.TRACK_FAVORITES) { + const itemInfo = (item as ITrackSimplified); + mbi_info.subtitle = itemInfo.artists[0].name || item.type; + } else if (section == Section.USERPRESETS) { + const itemInfo = (item as IUserPreset); + mbi_info.subtitle = itemInfo.subtitle || item.uri; + } + + //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. + * + * @param text Text string to replace keyword values with. + * @param config CardConfig configuration data. + * @param player MediaPlayer instance that contains information about the player. + * @param mediaListLastUpdatedOn Epoch date(in seconds) when the last refresh of the media list took place. Only used for services that don't have a media player `lastupdatedon` attribute. + * @param mediaList A media list of content items. + * @returns The text argument with keywords replaced with the equivalent attribute values. + */ +export function formatTitleInfo( + text: string | undefined, + config: CardConfig, + player: MediaPlayer | undefined = undefined, + mediaListLastUpdatedOn: number | undefined = undefined, + mediaList: Array | undefined = undefined, +): string | undefined { + + // call various formatting methods. + let result = formatConfigInfo(text, config); + result = formatPlayerInfo(result, player); + result = formatMediaListInfo(result, mediaListLastUpdatedOn, mediaList); + return result; +} + + +/** + * Formats a string with MediaList information. This method finds selected keywords + * and replaces them with the equivalent MediaList attribute values. + * + * @param text Text string to replace keyword values with. + * @param mediaListLastUpdatedOn Epoch date(in seconds) when the last refresh of the media list took place. Only used for services that don't have a media player `lastupdatedon` attribute. + * @param mediaList A media list of content items. + * @returns The text argument with keywords replaced with media list details. + */ +export function formatMediaListInfo( + text: string | undefined, + mediaListLastUpdatedOn: number | undefined = undefined, + mediaList: Array | undefined = undefined, +): string | undefined { + + // if text not set then don't bother. + if (!text) + return text; + + // if media list not set, then use an empty array to resolve to 0 items. + if (text.indexOf("{medialist.itemcount}") > -1) { + const count = (mediaList || []).length.toString(); + text = text.replace("{medialist.itemcount}", count); + } + + if (text.indexOf("{medialist.lastupdatedon}") > -1) { + const localeDT = formatDateEpochSecondsToLocaleString(mediaListLastUpdatedOn || 0); + text = text.replace("{medialist.lastupdatedon}", localeDT || ''); + } + + return text; +} + + +/** + * Formats a string with MediaPlayer information. This method finds selected keywords + * and replaces them with the equivalent MediaPlayer attribute values. + * + * @param text Text string to replace media player keyword values with. + * @param player MediaPlayer instance that contains information about the player. + * @returns The text argument with keywords replaced with media player details. + */ +export function formatPlayerInfo( + text: string | undefined, + player: MediaPlayer | undefined, + ): string | undefined { + + // if player instance not set then don't bother. + if (!player) + return text; + + // replace keyword parameters with media player equivalents. + if (text) { + + text = text.replace("{player.name}", player.name); + text = text.replace("{player.friendly_name}", player.attributes.friendly_name || ''); + text = text.replace("{player.source}", player.attributes.source || ''); + text = text.replace("{player.media_album_name}", player.attributes.media_album_name || ''); + text = text.replace("{player.media_artist}", player.attributes.media_artist || ''); + text = text.replace("{player.media_title}", player.attributes.media_title || ''); + text = text.replace("{player.media_track}", player.attributes.media_track?.toString() || ''); + text = text.replace("{player.state}", player.state || ''); + + // drop everything after the first parenthesis. + if (text.indexOf("{player.source_noaccount}") > -1) { + let value = player.attributes.source || ''; + const idx = value.indexOf('('); + if (idx > 0) { + value = value.substring(0, idx - 1); + } + text = text.replace("{player.source_noaccount}", (value || '').trim()); + } + + // spotifyplus extra state attributes. + 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_playlist_name}", player.attributes.sp_playlist_name || ''); + text = text.replace("{player.sp_playlist_name_title}", "Playlist: " + (player.attributes.sp_playlist_name || 'n/a')); + 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 || ''); + text = text.replace("{player.sp_user_email}", player.attributes.sp_user_email || ''); + text = text.replace("{player.sp_user_id}", player.attributes.sp_user_id || ''); + 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; +} + + +/** + * Formats a string with CardConfig information. This method finds selected keywords + * and replaces them with the equivalent CardConfig attribute values. + * + * The following replacement keywords are supported: + * - {config.pandoraUserAccount} : player name (e.g. "Livingroom Soundbar"). + * + * @param text Text string to replace configuration keyword values with. + * @param config CardConfig configuration data. + * @returns The text argument with keywords replaced with configuration details. + */ +export function formatConfigInfo( + text: string | undefined, + config: CardConfig, +): string | undefined { + + // if config instance not set then don't bother. + if (!config) + return text; + + // replace keyword parameters with configuration equivalents. + if (text) { + // TODO - possibly remove this? + //text = text.replace("{config.xxxx}", config.xxxx || ''); + } + + return text; +} + + +/** + * Style definition used to style a media browser item background image. + */ +export function styleMediaBrowserItemBackgroundImage(thumbnail: string, index: number, section: Section) { + + let bgSize = '100%'; + if (section == 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; + + // if media list exceeds max items, then truncate the list. + if ((mediaList?.length || 0) > maxItems) { + + result = "Limited to " + maxItems + " items while editing card configuration."; + + for (let i = 0, l = mediaList?.length || 0; i <= l; i++) { + if (i > maxItems) + mediaList?.pop() + } + } + + return result; + +} + + +/** + * Opens a new browser tab to the specified link. + * + * @param url Link to open. + */ +export function openWindowNewTab(url: string):void { + window.open(url, "_blank"); +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 0000000..86d3d3d --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1,422 @@ +// our imports. +import { CardConfig } from '../types/card-config'; +import { ConfigArea } from '../types/config-area'; +import { Section } from '../types/section'; + +export function cardDoesNotContainAllSections(config: CardConfig) { + return config.sections && config.sections.length < Object.keys(Section).length; +} + + +/** + * Defines a custom event type and it's details. + */ +export function customEvent(type: string, detail?: unknown) { + return new CustomEvent(type, { + bubbles: true, + composed: true, + detail, + }); +} + + +export function dispatch(type: string, detail?: unknown) { + const event = customEvent(type, detail); + document.dispatchEvent(event); +} + + +/** + * Unescapes html that has been stored in an escaped format. + * + * @param escapedHtml Escaped html value. + * @returns A string with the unescaped html. + */ +export function unescapeHtml(escapedHtml: string): string { + + if (escapedHtml) { + return escapedHtml.replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'"); + } else { + return ""; + } + +} + + +/** + * Formats an epoch date to a date locale string. + * + * The date is converted by a call to the `Date.toLocaleString()` method. + * + * @param epochSeconds Epoch date to format (e.g. number of seconds since 01/01/1970. + * @returns A string with the formatted date. + */ +export function formatDateEpochSecondsToLocaleString(epochSeconds: number | undefined): string | undefined { + + // if epoch date not specified then don't bother. + if (!epochSeconds) + return undefined + + // 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 +} + + +/** + * Formats a milliseconds value to "HH:MM:SS" value. + * + * @param ms Milliseconds to format. + * @returns Minutes and seconds portion of the timestamp if hours is "00:"; otherwise, return the hours, minutes, and seconds portion of the timestamp. + */ +export function formatDateHHMMSSFromMilliseconds(ms: number) { + + // create a timestamp from specified milliseconds value. + const date = new Date(ms).toISOString().substring(11, 19); + + // return the minutes and seconds portion of the timestamp if hours is "00:"; + // otherwise, return the hours, minutes, and seconds portion of the timestamp. + return date.startsWith('00:') ? date.substring(3) : date; + +} + + +/** +* Converts a string value to proper case. +* +* @param str String to convert to propercase (e.g. "hello world"). +* @returns A properly cased string value (e.g. "Hello World"). +*/ +export function formatStringProperCase(str: string): string | void { + let upper = true; + let newStr = ""; + for (let i = 0, l = str.length; i < l; i++) { + if (str[i] == " ") { + upper = true; + newStr += " "; + continue; + } + newStr += upper ? str[i].toUpperCase() : str[i].toLowerCase(); + upper = false; + } + return newStr; +} + + +/** + * Returns a Section value for the supplied ConfigArea. + * + * @param configArea ConfigArea to retrieve the corresponding section value for. + */ +export function getSectionForConfigArea(configArea: ConfigArea) { + + // get section value for supplied ConfigArea value. + let section = Section.UNDEFINED; + if (configArea == ConfigArea.ALBUM_FAVORITES) { + section = Section.ALBUM_FAVORITES; + } else if (configArea == ConfigArea.ARTIST_FAVORITES) { + section = Section.ARTIST_FAVORITES; + } else if (configArea == ConfigArea.AUDIOBOOK_FAVORITES) { + section = Section.AUDIOBOOK_FAVORITES; + } else if (configArea == ConfigArea.DEVICE_BROWSER) { + section = Section.DEVICES; + } else if (configArea == ConfigArea.EPISODE_FAVORITES) { + section = Section.EPISODE_FAVORITES; + } else if (configArea == ConfigArea.GENERAL) { + section = Section.PLAYER; + } else if (configArea == ConfigArea.PLAYER) { + section = Section.PLAYER; + } else if (configArea == ConfigArea.PLAYLIST_FAVORITES) { + section = Section.PLAYLIST_FAVORITES; + } else if (configArea == ConfigArea.RECENT_BROWSER) { + section = Section.RECENTS; + } else if (configArea == ConfigArea.SEARCH_MEDIA_BROWSER) { + section = Section.SEARCH_MEDIA; + } else if (configArea == ConfigArea.SHOW_FAVORITES) { + section = Section.SHOW_FAVORITES; + } else if (configArea == ConfigArea.TRACK_FAVORITES) { + section = Section.TRACK_FAVORITES; + } else if (configArea == ConfigArea.USERPRESET_BROWSER) { + section = Section.USERPRESETS; + } + + return section; +} + + +/** + * Returns a ConfigArea value for the supplied Section value. + * + * @param configArea Section value to retrieve the corresponding ConfigArea value for. + */ +export function getConfigAreaForSection(section: Section) { + + // get section value for supplied ConfigArea value. + let configArea = ConfigArea.GENERAL; + if (section == Section.ALBUM_FAVORITES) { + configArea = ConfigArea.ALBUM_FAVORITES; + } else if (section == Section.ARTIST_FAVORITES) { + configArea = ConfigArea.ARTIST_FAVORITES; + } else if (section == Section.AUDIOBOOK_FAVORITES) { + configArea = ConfigArea.AUDIOBOOK_FAVORITES; + } else if (section == Section.DEVICES) { + configArea = ConfigArea.DEVICE_BROWSER; + } else if (section == Section.EPISODE_FAVORITES) { + configArea = ConfigArea.EPISODE_FAVORITES; + } else if (section == Section.PLAYER) { + configArea = ConfigArea.PLAYER; + } else if (section == Section.PLAYLIST_FAVORITES) { + configArea = ConfigArea.PLAYLIST_FAVORITES; + } else if (section == Section.RECENTS) { + configArea = ConfigArea.RECENT_BROWSER; + } else if (section == Section.SEARCH_MEDIA) { + configArea = ConfigArea.SEARCH_MEDIA_BROWSER; + } else if (section == Section.SHOW_FAVORITES) { + configArea = ConfigArea.SHOW_FAVORITES; + } else if (section == Section.TRACK_FAVORITES) { + configArea = ConfigArea.TRACK_FAVORITES; + } else if (section == Section.USERPRESETS) { + configArea = ConfigArea.USERPRESET_BROWSER; + } + + return configArea; +} + + +/** + * Returns true if the dashboard editor is active; + * otherwise, false. + * + * HA uses "?edit=1" querystring to denote dashboard is in edit mode. + */ +export function isCardInDashboardEditor() { + + // get current url querystring. + const queryString = window.location.search; + const urlParms = new URLSearchParams(queryString); + + // is `edit=1` parameter present? if so, then the dashboard is in edit mode. + const urlParmEdit = urlParms.get('edit'); + let result = false; + if (urlParmEdit == '1') { + result = true; + } + + return result; + +} + + +/** + * Returns true if the card is currently being previewed in the card editor; + * otherwise, false. + * + * The parentElement structure will look like the following when the MAIN card + * is in edit preview mode (in the card configuration editor preview pane): + * + * (HA 2024.08.1 release): + * - parentElement1.tagName='HUI-CARD', className=undefined + * - parentElement2.tagName='DIV', className='element-preview' + * - parentElement3.tagName='DIV', className='content' + * - parentElement4.tagName='HA-DIALOG', className=undefined + * + * The parentElement structure will look like the following when the EDITOR card + * is in edit preview mode (in the card configuration editor preview pane): + * + * (HA 2024.08.1 release): + * - parentElement1.tagName='DIV', className='gui-editor' + * - parentElement2.tagName='DIV', className='wrapper' + */ +export function isCardInEditPreview(cardElement: Element) { + + let parent1Cls: string | undefined = undefined; + let parent2Cls: string | undefined = undefined; + + // get parent element data. + if (cardElement) { + + //console.log("isCardInEditPreview - ParentElement tagName info:\n parentElement1: %s = %s\n parentElement2: %s = %s\n parentElement3: %s = %s\n parentElement4: %s = %s\n parentElement5: %s = %s\n parentElement6: %s = %s\n parentElement7: %s = %s", + // cardElement.parentElement?.tagName, cardElement.parentElement?.className, + // cardElement.parentElement?.parentElement?.tagName, cardElement.parentElement?.parentElement?.className, + // cardElement.parentElement?.parentElement?.parentElement?.tagName, cardElement.parentElement?.parentElement?.parentElement?.className, + // cardElement.parentElement?.parentElement?.parentElement?.parentElement?.tagName, cardElement.parentElement?.parentElement?.parentElement?.parentElement?.className, + // cardElement.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.tagName, cardElement.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.className, + // cardElement.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.tagName, cardElement.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.className, + // cardElement.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.tagName, cardElement.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.className, + //); + + const parent1Elm = cardElement.parentElement; + if (parent1Elm) { + parent1Cls = (parent1Elm.className || '').trim(); + const parent2Elm = parent1Elm.parentElement; + if (parent2Elm) { + parent2Cls = (parent2Elm.className || '').trim(); + } + } + } else { + // cardElement was undefined. + } + + // check if the main or editor cards are in the configuration editor preview pane. + let result = false; + if (parent2Cls === 'element-preview') { + // MAIN card is in the configuration editor preview pane. + result = true; + } else if (parent1Cls === 'gui-editor') { + // EDITOR card is in the configuration editor preview pane. + result = true; + } + + return result; +} + + +/** + * Returns true if the card is currently being previewed in the card picker + * dialog, which is used when adding a card to a UI dashboard; + * otherwise, false. + * + * The parentElement structure will look like the following when the MAIN card + * is in card picker preview mode (in the card picker preview pane): + * + * (HA 2024.08.1 release): + * - parentElement1.tagName='DIV', className='preview ' + * - parentElement2.tagName='DIV', className='card' + * - parentElement3.tagName='DIV', className='cards-container' + * - parentElement4.tagName='DIV', className=undefined + */ +export function isCardInPickerPreview(cardElement: Element) { + + let parent1Cls: string | undefined = undefined; + let parent2Cls: string | undefined = undefined; + let parent3Cls: string | undefined = undefined; + + // get parent element data. + if (cardElement) { + + //console.log("isCardInEditPreview - ParentElement tagName info:\n parentElement1: %s = %s\n parentElement2: %s = %s\n parentElement3: %s = %s\n parentElement4: %s = %s\n parentElement5: %s = %s\n parentElement6: %s = %s\n parentElement7: %s = %s", + // cardElement.parentElement?.tagName, cardElement.parentElement?.className, + // cardElement.parentElement?.parentElement?.tagName, cardElement.parentElement?.parentElement?.className, + // cardElement.parentElement?.parentElement?.parentElement?.tagName, cardElement.parentElement?.parentElement?.parentElement?.className, + // cardElement.parentElement?.parentElement?.parentElement?.parentElement?.tagName, cardElement.parentElement?.parentElement?.parentElement?.parentElement?.className, + // cardElement.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.tagName, cardElement.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.className, + // cardElement.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.tagName, cardElement.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.className, + // cardElement.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.tagName, cardElement.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.className, + //); + + const parent1Elm = cardElement.parentElement; + if (parent1Elm) { + parent1Cls = (parent1Elm.className || '').trim(); + const parent2Elm = parent1Elm.parentElement; + if (parent2Elm) { + parent2Cls = (parent2Elm.className || '').trim(); + const parent3Elm = parent2Elm.parentElement; + if (parent3Elm) { + parent3Cls = (parent3Elm.className || '').trim(); + } + } + } + } else { + // cardElement was undefined. + } + + // check if the card is in the card picker preview pane. + let result = false; + if ((parent1Cls === 'preview') && (parent2Cls === 'card') && (parent3Cls === 'cards-container')) { + result = true; + } + + return result; +} + + +/** + * Check if a string is a numeric value or not. + * + * @param numStr String to check for a numeric value. + * @returns true if the specified string can be converted to a number; otherwise, false. + */ +export function isNumber(numStr: string): boolean { + return !isNaN(parseFloat(numStr)) && !isNaN(+numStr) +} + + +export function getObjectDifferences(obj1: any, obj2: any): any { + + if (typeof obj1 !== 'object' || typeof obj2 !== 'object') { + return obj1 !== obj2 ? [obj1, obj2] : undefined; + } + + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + const uniqueKeys = new Set([...keys1, ...keys2]); + + const differences: any = {}; + + for (const key of uniqueKeys) { + const value1 = obj1[key]; + const value2 = obj2[key]; + + if (typeof value1 === 'object' && typeof value2 === 'object') { + const nestedDifferences = getObjectDifferences(value1, value2); + if (nestedDifferences) { + differences[key] = nestedDifferences; + } + } else if (value1 !== value2) { + differences[key] = [value1, value2]; + } + } + + return Object.keys(differences).length === 0 ? undefined : differences; +} + + +/** + * Find the closest matching element in a chain of nested, slotted custom elements. + * + * @param selector selector used to find the element; values are case-sensitive. + * @param base element to start searching from; specify `this` to start searching from the current element. + * @returns a matching element if found; otherwise, null. + * + * examples: + * - find element by it's `id=` value: + * const container = this.closestElement('#spcPlayer'); + * - find element by it's html tag name (e.g. ``): + * const container = this.closestElement('spc-player'); + */ +export function closestElement(selector: string, base: Element) { + + function __closestFrom(el: Element | Window | Document | null): Element | null { + if (!el || el === document || el === window) return null; + if ((el as Slottable).assignedSlot) el = (el as Slottable).assignedSlot; + + const found = (el as Element).closest(selector); + return found + ? found + : __closestFrom(((el as Element).getRootNode() as ShadowRoot).host); + } + return __closestFrom(base); +} + + +/** + * Determine if the current device supports touch events. + * + * @returns true if touch events are supported; otherwise, false. + * + * examples: + * - find element by it's `id=` value: + * const container = this.closestElement('#spcPlayer'); + * - find element by it's html tag name (e.g. ``): + * const container = this.closestElement('spc-player'); + */ +export function isTouchDevice(): boolean { + return 'ontouchstart' in window || navigator.maxTouchPoints > 0; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..63fa282 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + // Language Options + "target": "ES2021", + "lib": [ "ES2021", "DOM", "DOM.Iterable", "WebWorker" ], + "experimentalDecorators": true, + // Modules + "module": "ESNext", + "moduleResolution": "node", + "resolveJsonModule": true, + // Babel handles transpiling and no need for declaration files + "noEmit": true, + // Caching + ///"incremental": true, + ///"tsBuildInfoFile": "node_modules/.cache/typescript/.tsbuildinfo", + // Type checking options + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "strict": true, + "noImplicitAny": false, + // Do not check type declaration files + "skipLibCheck": true, + // Interop with CommonJS and other tools + "esModuleInterop": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + + // settings used for debugging. + "sourceMap": true, // Generate sourcemaps + "declaration": false // Generate .d.ts files + } +} \ No newline at end of file