├── .github ├── CODEOWNERS ├── delete-merged-branch-config.yml ├── labeler.yml ├── release-drafter.yml └── workflows │ └── node.js.yml ├── .gitignore ├── LICENSE ├── MIT_LICENSE ├── README.md ├── gitignorefiles ├── README.md ├── gradle.ignore ├── java.ignore ├── jboss.ignore ├── node.ignore ├── saas.ignore └── wordpress.ignore └── sample-app ├── Client ├── .gitignore ├── Dockerfile ├── package-lock.json ├── package.json ├── public │ ├── index.html │ └── robots.txt ├── run.sh ├── src │ ├── App │ │ ├── Authentication │ │ │ ├── authentication.js │ │ │ ├── createAuthToken.js │ │ │ └── refreshAuthToken.js │ │ ├── Components │ │ │ ├── GroupSubComponents │ │ │ │ ├── imageComponent.jsx │ │ │ │ ├── playbackMetaDataComponent.jsx │ │ │ │ ├── playbackMetaDataComponentWrapper.js │ │ │ │ ├── playbackStateButton.jsx │ │ │ │ ├── playbackStateButtonWrapper.js │ │ │ │ ├── serviceLogoComponent.jsx │ │ │ │ ├── volumeComponent.jsx │ │ │ │ └── volumeComponentWrapper.js │ │ │ ├── backButtonComponent.jsx │ │ │ ├── favoriteComponent.jsx │ │ │ ├── groupPlaybackComponent.jsx │ │ │ ├── groupPlaybackComponentWrapper.js │ │ │ ├── headerComponent.jsx │ │ │ ├── listGroupsComponent.jsx │ │ │ ├── listHouseholdsComponent.jsx │ │ │ ├── playerComponent.jsx │ │ │ ├── playerComponentWrapper.js │ │ │ └── playlistComponent.jsx │ │ ├── ControlAPIs │ │ │ ├── getGroupVolume.js │ │ │ ├── getPlaybackState.js │ │ │ ├── getPlayerVolume.js │ │ │ ├── playbackMetadata.js │ │ │ ├── playerControls.js │ │ │ └── setVolume.js │ │ ├── Controllers │ │ │ ├── favoritesController.jsx │ │ │ ├── fetchGroupsController.jsx │ │ │ ├── fetchGroupsControllerWrapper.js │ │ │ ├── fetchHouseholdsController.jsx │ │ │ ├── groupGoneRoutingController.js │ │ │ ├── groupRoutingController.jsx │ │ │ ├── householdRoutingController.jsx │ │ │ ├── oAuthController.jsx │ │ │ ├── playersController.jsx │ │ │ ├── playlistsController.jsx │ │ │ └── routingController.jsx │ │ ├── MuseDataHandlers │ │ │ ├── GroupsInfoHandler.js │ │ │ ├── PlaybackMetadataHandler.js │ │ │ ├── PlaybackStateHandler.js │ │ │ ├── SelectedGroupHandler.js │ │ │ └── VolumeHandler.js │ │ ├── Recoil │ │ │ ├── groupsInfoAtom.js │ │ │ ├── playbackMetadataAtom.js │ │ │ ├── playbackStateAtom.js │ │ │ ├── playerVolumeAtomFamily.js │ │ │ ├── selectedGroupAtom.js │ │ │ └── volumeAtom.js │ │ ├── Routing │ │ │ ├── routeGroup.js │ │ │ └── routeHousehold.js │ │ ├── UnitTests │ │ │ ├── createAuthToken.test.js │ │ │ ├── getGroups.test.js │ │ │ ├── getHouseholdID.test.js │ │ │ ├── refreshAuthToken.test.js │ │ │ ├── routeHousehold.test.js │ │ │ └── testConfig.json │ │ ├── UserDetails │ │ │ ├── getFavorites.js │ │ │ ├── getGroups.js │ │ │ ├── getHouseholds.js │ │ │ ├── getPlaylists.js │ │ │ ├── getServiceProviderLogos.js │ │ │ ├── groupsSubscribe.js │ │ │ ├── playerVolumeSubscribe.js │ │ │ └── subscribe.js │ │ ├── Utility │ │ │ └── helper.js │ │ ├── WebSocket │ │ │ ├── MuseEventHandler.js │ │ │ └── socket.js │ │ └── museClient │ │ │ ├── OAS_production.json │ │ │ ├── README.md │ │ │ ├── api.js │ │ │ └── configuration.js │ ├── config.json │ ├── css │ │ ├── controlPage.css │ │ ├── dashboard.css │ │ ├── login.css │ │ ├── navbar.css │ │ └── players.css │ ├── images │ │ ├── Sample App - SONOS_files │ │ │ └── logo.62c8a02825cb8d902bab.png │ │ ├── logo.png │ │ ├── logout.png │ │ ├── repeat.png │ │ ├── repeat_one.png │ │ ├── shuffle.png │ │ ├── sonos.png │ │ └── sonos_background.png │ ├── index.js │ └── setupTests.js └── webpack.config.js ├── Cors ├── Dockerfile ├── cors-format.js ├── package-lock.json └── package.json ├── Dockerfile ├── Server ├── .gitignore ├── Dockerfile ├── main.mjs ├── package-lock.json └── package.json ├── Test-cors ├── group.txt ├── household.txt ├── mock-cors.js ├── package-lock.json └── package.json ├── docker-compose.yml └── tools └── postmanCollection └── Sonos Control API.postman_collection.json /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Specify what team is responsible for this repo 2 | # 3 | # More Details: 4 | # https://help.github.com/en/articles/about-code-owners 5 | 6 | # * @Sonos-Inc/YOUR-TEAM-NAME-HERE -------------------------------------------------------------------------------- /.github/delete-merged-branch-config.yml: -------------------------------------------------------------------------------- 1 | # Automatically deletes a branch after it's merged 2 | # 3 | # More Details: 4 | # https://probot.github.io/apps/delete-merged-branch/ 5 | 6 | exclude: 7 | - main 8 | - develop 9 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | # Label issues based on the title and body agains a list of labels 2 | # 3 | # More Details: 4 | # https://probot.github.io/apps/issuelabeler/ 5 | 6 | # Number of labels to fetch (optional). Defaults to 20 7 | numLabels: 20 8 | # These labels will not be used even if the issue contains them 9 | # excludeLabels: 10 | # - pinned 11 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | # Drafts release notes as pull requests are merged into main 2 | # 3 | # More Details: 4 | # https://probot.github.io/apps/release-drafter/ 5 | 6 | name-template: v$NEXT_PATCH_VERSION 7 | tag-template: v$NEXT_PATCH_VERSION 8 | categories: 9 | - title: 🚀 Features 10 | label: feature 11 | - title: 🐛 Bug Fixes 12 | label: fix 13 | - title: 🧰 Maintenance 14 | label: maintenance 15 | # tag-template: - $TITLE @$AUTHOR (#$NUMBER) 16 | 17 | template: | 18 | ## Changes 19 | 20 | $CHANGES 21 | 22 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: [self-hosted, bionic] 13 | timeout-minutes: 10 14 | defaults: 15 | run: 16 | working-directory: 'sample-app/Client' 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | # TEMPORARY until we get a runner on ubuntu focal (20.4) or jammy (22.4) 22 | - name: Install Sonos Node 23 | run: | 24 | wget https://packages.sonos.com/bootstrap-no-check.sh 25 | chmod +x bootstrap-no-check.sh 26 | sudo ./bootstrap-no-check.sh 27 | sudo apt install -y sonos-nodejs-18.12.1 28 | sudo rm /usr/bin/node 29 | sudo ln -s /usr/local/nodejs-18.12.1/bin/node /usr/bin/node 30 | sudo rm /usr/bin/npm 31 | sudo ln -s /usr/local/nodejs-18.12.1/bin/npm /usr/bin/npm 32 | 33 | 34 | # Proper way to setup node once we have a more updated ubuntu runner 35 | # - name: Setup Node 36 | # uses: actions/setup-node@v3 37 | # with: ${{ env.NODE_VERSION }} 38 | 39 | - name: Install dependencies 40 | run: npm ci 41 | working-directory: 'sample-app/Client' 42 | 43 | - name: Run mock Server 44 | run: node mock-cors.js & 45 | working-directory: 'sample-app/Test-cors' 46 | 47 | - name: Run tests 48 | run: npm test 49 | working-directory: 'sample-app/Client' 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Specifies intentionally untracked files to ignore 2 | 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | # User-specific stuff: 6 | .idea/**/workspace.xml 7 | .idea/**/tasks.xml 8 | .idea/dictionaries 9 | .idea/ 10 | 11 | # Sensitive or high-churn files: 12 | .idea/**/dataSources/ 13 | .idea/**/dataSources.ids 14 | .idea/**/dataSources.xml 15 | .idea/**/dataSources.local.xml 16 | .idea/**/sqlDataSources.xml 17 | .idea/**/dynamic.xml 18 | .idea/**/uiDesigner.xml 19 | 20 | # Gradle: 21 | .idea/**/gradle.xml 22 | .idea/**/libraries 23 | 24 | # CMake 25 | cmake-build-debug/ 26 | 27 | # Mongo Explorer plugin: 28 | .idea/**/mongoSettings.xml 29 | 30 | ## File-based project format: 31 | *.iws 32 | 33 | ## Plugin-specific files: 34 | 35 | # IntelliJ 36 | out/ 37 | 38 | # mpeltonen/sbt-idea plugin 39 | .idea_modules/ 40 | 41 | # JIRA plugin 42 | atlassian-ide-plugin.xml 43 | 44 | # Cursive Clojure plugin 45 | .idea/replstate.xml 46 | 47 | # Crashlytics plugin (for Android Studio and IntelliJ) 48 | com_crashlytics_export_strings.xml 49 | crashlytics.properties 50 | crashlytics-build.properties 51 | fabric.properties 52 | 53 | ### OSX ### 54 | *.DS_Store 55 | .AppleDouble 56 | .LSOverride 57 | 58 | # Icon must end with two \r 59 | Icon 60 | 61 | # Thumbnails 62 | ._* 63 | 64 | # Files that might appear in the root of a volume 65 | .DocumentRevisions-V100 66 | .fseventsd 67 | .Spotlight-V100 68 | .TemporaryItems 69 | .Trashes 70 | .VolumeIcon.icns 71 | .com.apple.timemachine.donotpresent 72 | 73 | # Directories potentially created on remote AFP share 74 | .AppleDB 75 | .AppleDesktop 76 | Network Trash Folder 77 | Temporary Items 78 | .apdisk 79 | /sample-app/Cors/node_modules/ 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | SONOS LICENSE 2 | 3 | This software is the exclusive property of Sonos Inc. No license is granted or implied for use by any entity except Sonos Inc. 4 | 5 | Contributions of any kind to this software are considered property of Sonos Inc. and shall not encumber or otherwise restrict Sonos Inc in any way. 6 | 7 | Contributor releases all rights for any contributions to Sonos Inc. for use in any means seen fit by Sonos Inc. 8 | 9 | Contributions of any kind to this software do not grant any license to use this software. 10 | 11 | Contributions of any kind do not create any rights, partnership, joint venture, or any form of agreement with Sonos Inc. 12 | 13 | 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 14 | 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 15 | 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. 16 | -------------------------------------------------------------------------------- /MIT_LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c)2017 Sonos, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sample-App (Web-based) 2 | 3 | This repo contains the source code for the web-based Sample App, which uses the Sonos API rather than LAN to send actions to and get the status of your Sonos system. 4 | The purpose of this Sample App is to showcase some examples of how you can integrate your application with Sonos controls. Feel free to use it as a reference or even 5 | download it to use as a template for your own applications. 6 | 7 | ### Features 8 | - Authentication 9 | - Fetching households and groups information 10 | - System control actions 11 | - Play/pause 12 | - Skip to next/previous track 13 | - Shuffle and repeat/repeat 1 14 | - Group volume control 15 | - Player volume control 16 | - Grouping/ungrouping players 17 | - Subscriptions for the following event types: 18 | - Household groups change 19 | - Group status 20 | - Group volume 21 | - Player volume 22 | - Playback state 23 | - Playback metadata 24 | - Eventing & state management 25 | - Fetches initial state of all listed event types and updates state based on received events 26 | - Fetching household's Favorites/Playlists and initiating playback 27 | - Fetching music service provider logos 28 | 29 | >**Note: This sample app if for demonstration purposes only. Some of the authorization logic happens client-side and that is not recommeded for production. Please see the [Sonos Developer Documentation](https://docs.sonos.com/docs/authorize) for best practices.** 30 | 31 | 32 | ## Table of Contents 33 | 34 | - [Requirements](#requirements) 35 | - [Setup and Configuration](#setup-and-configuration) 36 | - [Reference](#reference) 37 | - [Client Flow](#client-flow) 38 | - [Server/ngrok/WebSocket Structure](#serverngrokwebsocket-structure) 39 | - [Eventing Structure](#eventing-structure) 40 | - [Authentication](#authentication) 41 | - [Login/Households Page](#loginhouseholds-page) 42 | - [Groups Page](#groups-page) 43 | - [Group Playback Page](#group-playback-page) 44 | 45 | # Requirements 46 | 47 | - Nodejs: https://nodejs.org/en/download/ 48 | - A tunneling service, some options include: 49 | - [Cloudflare Tunneled](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) - Best if you have a custom domain and can change DNS to Cloudflare 50 | - [Localtunnel](https://theboroer.github.io/localtunnel-www/) - Open source and allows you to request a sub-domain 51 | - [ngrok](https://ngrok.com/) - Reliable but URL is temporary with the free version 52 | - >**Note: There is a known issue where ngrok does not receive some events. This is a limitation of running the server locally and can be fixed by implementing your own remote server.** 53 | - typescript: https://www.typescriptlang.org/download 54 | - CORS Anywhere: Run `npm install cors-anywhere` in terminal/command prompt (must have installed Nodejs already) 55 | 56 | # Setup and Configuration 57 | 1. Open terminal/command prompt and clone this repository. 58 | 2. In terminal/command prompt, start your tunneling service. It should provide a public URL. Example commands (you only need to run 1 of the following): 59 | - `cloudflared tunnel --url http://localhost:8080` (temporary tunnel for quick testing) 60 | - `cloudflared tunnel run --token ` (after setting up domain, make sure to point it to http://localhost:8080) 61 | - `localtunnel --port 8080 --subdomain my-custom-domain` (requesting a subdomain is recommended to avoid changing public URL) 62 | - `ngrok http 8080` 63 | 3. Create client credentials 64 | 1. Navigate to https://developer.sonos.com/ 65 | 2. Create an account and login. 66 | 3. Navigate to Control Integrations 67 | 4. Create a new Control Integration. 68 | 5. In the "Redirect URI" field, enter your public forwarding URL from step 2 with the path `/oauth`. (Ex: https://test.trycloudflare.com/oauth). Note that if you don't use a custom domain, the forwarding URL will change every time you restart the tunneling service, the Redirect URI must be updated to the new URL. 69 | 6. In the "Event Callback URL" field, enter your public forwarding URL from step 2. The Event Callback URL must also be updated everytime your forwarding URL changes. 70 | 71 | > Note - for more guidance on creation of key/secrets and their uses, go to https://docs.sonos.com/docs/control-sonos-players 72 | 4. Configure authentication 73 | 1. Open the file [config.json](sample-app/Client/src/config.json) in the location - `sample-app/Client/src/` 74 | 2. Replace `` and `` with your Sonos control integration API key client ID and secret, respectively. Replace `/oauth (ex: https://test.trycloudflare.com/oauth)` with your public fowarding URL from step 2 with `/oauth` as path. 75 | 5. Start the Application 76 | 77 | a) **With Docker**: Recommended for testing the Sample App's capabilities 78 | 79 | Note - you must have Docker for Desktop installed to perform these steps - https://www.docker.com/products/docker-desktop/: 80 | 81 | i. Ensure Docker is running. 82 | 83 | ii. In a terminal/command prompt, navigate to this repository and enter the `sample-app` directory 84 | 85 | iii. Run the command `docker-compose up --build` 86 | 87 | b) **Without Docker**: Recommended for development, as code changes are automatically recompiled 88 | 89 | i. Enter the `sample-app/Cors` directory and run `node cors-format` 90 | 91 | ii. In a different terminal/command prompt, navigate to `sample-app/Server/`, and run `npm install` 92 | 93 | iii. After the install, run `npm start` 94 | 95 | iv. Open a diferent terminal/command prompt, navigate to `sample-app/Client`, and run `npm install` 96 | 97 | v. After the install, run `npm start` 98 | 99 | vi. Ensure to keep both terminal/command prompt open. Do not close. 100 | 101 | 6. Access the application at http://localhost:3000 102 | 103 | ## Open API Specification Generator 104 | The steps for open API spec generation are available in the [README in sample-app/Client/src/App/museClient](sample-app/Client/src/App/museClient/README.md) 105 | 106 | # Reference 107 | See [Sonos Developer Documentation](https://docs.sonos.com/reference/about-control-api) for information on specific API commands. 108 | 109 | ## Client Flow 110 | Client flow diagram 111 | 112 | ## Server/ngrok/WebSocket Structure 113 | >Note: for the following, we will assume you are using ngrok as your tunneling service. 114 | 115 | The sample application has two main parts: the client and the server. The client handles all user-facing components, while the server listens for Sonos 116 | API events and sends those events to the client via a WebSocket connection. 117 | 118 | ![Client/Server/ngrok sequence diagram](https://github.com/sonos/api-web-sample-app/assets/67022827/4b27544d-7009-4e86-9ca0-eb28ae37cb29) 119 | 120 | In order for the server to receive Sonos API events, the in-use Sonos Control Integration API key must specify a URL for the events to be sent to. For the purpose of demonstrating this Sample App, 121 | ngrok exposes a port on your computer and creates a public URL that allows events to be sent directly to the specified port (8080 in this case). 122 | [Server/main.mjs](sample-app/Server/main.mjs) sets up a server that listens to events sent to port 8080, so any events sent to the ngrok URL are received by the server. 123 | 124 | To allow the server to send messages to the client, [Server/main.mjs](sample-app/Server/main.mjs) sets up a WebSocket connection on port 8000. Each time the 125 | server receives an event, it sends that event to the WebSocket connection. To receive these messages, the client uses the configuration specified in [socket.js](sample-app/Client/src/App/WebSocket/socket.js) 126 | and listens to the WebSocket at ws://localhost:8000 in [`MuseEventHandler`](sample-app/Client/src/App/WebSocket/MuseEventHandler.js). [`MuseEventHandler`](sample-app/Client/src/App/WebSocket/MuseEventHandler.js) 127 | is active while the application is active regardless of which page the user is on, allowing information to be updated when on both the groups page and the group 128 | playback page. 129 | 130 | ### ngrok Limitations and Scaling 131 | While using the free tier of ngrok, you'll likely notice that many events are not received by the server, especially after a couple of minutes of event handling. 132 | This is a limitation of using ngrok and running the server locally. For a scalable app that uses Sonos control integrations and has event handling, there must be 133 | an external server that receives events and sends them through WebSocket connections to clients. The Sonos control integration API key callback URL 134 | would be then set to that external server's URL. 135 | 136 | Despite the severe performance limitations, ngrok is helpful for demonstrating subscriptions and eventing for a Sonos control client. This exact structure 137 | should not be used in a production-ready app. 138 | 139 | ## Eventing Structure 140 | Eventing flow diagram 141 | 142 | #### Subscriptions 143 | Upon navigating to the groups page or the group player page, the information of various aspects of your Sonos system is fetched and stored. This information can quickly 144 | become outdated when, for example, there is a grouping change in the current household, the currently playing song is changed, a player's volume is changed, 145 | etc. To solve this, Sonos control integrations use subscriptions (See https://devdocs.sonos.com/docs/subscribe). Subscribing to an event type for a group or 146 | household will cause all changes in that type's state for the specified group/household to be sent to the API key callback URL as events. It is important to be able to update the state of 147 | components based on these events to ensure the most up-to-date information is always being displayed. 148 | 149 | This event/subscription model is used instead of polling to ensure more timely updates to component states and to reduce the strain on both the device running 150 | the application and the Sonos API. Within the sample app, each subscription component subscribes when mounted, and when unmounted, the subscription is deleted. 151 | See [subscribe.js](sample-app/Client/src/App/UserDetails/subscribe.js) for an example. 152 | 153 | #### Event Handling 154 | Event handling uses [Recoil](https://recoiljs.org/) ([learn more here](https://recoiljs.org/docs/introduction/core-concepts)) to keep track of the playback state, playback metadata, 155 | group volume, group status, player volume, and the current household's groups information independently of any component. With the exception of player volume, each of these pieces of state is 156 | represented by a [Recoil Atom](sample-app/Client/src/App/Recoil), which is updated and accessed by calling the result 157 | of `useRecoilState(AtomName)`. As the number of players is variable, player volume is represented by a Recoil Atom Family, which is updated and accessed by calling the result of `useRecoilState(AtomFamilyName(PlayerID))`. 158 | 159 | When the group playback page is navigated to, the atoms are updated by fetching the current state of the group and household from the Sonos API. From then on, any 160 | subsequent updates to the playback state are through eventing. There is a single event listener ([`MuseEventHandler`](sample-app/Client/src/App/WebSocket/MuseEventHandler.js)) 161 | that, when it receives an event, calls the relevant function in [`MuseDataHandlers`](sample-app/Client/src/App/MuseDataHandlers) to format the response and then 162 | uses this formatted response to update the respective Recoil Atom. The groups page uses a similar process but only for `groupsInfoAtom`, since this page only requires 163 | access to information on the selected household's groups. 164 | 165 | To allow for [GroupPlaybackComponent](sample-app/Client/src/App/Components/groupPlaybackComponent.jsx), [group playback subcomponents](sample-app/Client/src/App/Components/GroupSubComponents), 166 | and [PlayerComponent](sample-app/Client/src/App/Components/playerComponent.jsx) to access and modify the state of the Atoms, the components are each created within a wrapper functional component, in which the 167 | result of `useRecoilState(AtomName)` or `useRecoilState(AtomFamilyName(PlayerID))` is passed through props, often as `state` and `setState`. Any external or internal changes to the Atom's state are reflected in 168 | `this.props.state` and the component is automatically re-rendered to reflect the change. Additionally, calling `this.props.setState(newState)` within a 169 | component modifies its Atom's state as well as its `this.props.state` field. 170 | 171 | #### Example/Walkthrough for Playback Metadata 172 | In [`groupPlayersComponent`](sample-app/Client/src/App/Components/groupPlaybackComponent.jsx), `PlaybackMetaDataComponentWrapper` is called, with the current 173 | group ID and configuration passed through props. In [`PlaybackMetaDataComponentWrapper`](sample-app/Client/src/App/Components/GroupSubComponents/playbackMetaDataComponentWrapper.js), 174 | `useRecoilState(playbackMetadataAtom)` is called and passed into `PlaybackMetaDataComponent` through props as `state` and `setState`, along with the group ID and configuration. 175 | 176 | [`Subscribe`](sample-app/Client/src/App/UserDetails/subscribe.js) is also called in [`groupPlayersComponent`](sample-app/Client/src/App/Components/groupPlaybackComponent.jsx). 177 | Upon [`Subscribe`](sample-app/Client/src/App/UserDetails/subscribe.js) mounting, among other event types, playback metadata events for the selected group are subscribed to. This means that whenever the current track name, 178 | artist name, container name, cover art, or music service changes, the sample app is sent an event containing the new playback metadata state. When the user 179 | navigates off of the group playback page, these events are unsubscribed to, as there is no need to keep track of playback metadata anymore. See 180 | https://devdocs.sonos.com/reference/playbackmetadata-subscribe for more details. 181 | 182 | When [`PlaybackMetaDataComponent`](sample-app/Client/src/App/Components/GroupSubComponents/playbackMetaDataComponent.jsx) 183 | is first created, it calls [`PlaybackMetadata`](sample-app/Client/src/App/ControlAPIs/playbackMetadata.js), which uses the group ID and configuration to make an API request to get the current group's 184 | playback metadata. Once the API response is received, [`PlaybackMetadataHandler`](sample-app/Client/src/App/MuseDataHandlers/PlaybackMetadataHandler.js) is called 185 | to properly format the request data. `playbackMetadataAtom`'s state is then set to equal the formatted data, and since the atom's state was passed into `PlaybackMetaDataComponent` 186 | through props, the component automatically re-renders to display the new playback metadata. 187 | 188 | Once the initial value is set, any playback metadata events received by [`MuseEventHandler`](sample-app/Client/src/App/WebSocket/MuseEventHandler.js) are passed 189 | through [`PlaybackMetadataHandler`](sample-app/Client/src/App/MuseDataHandlers/PlaybackMetadataHandler.js) and the state of `playbackMetadataAtom` is updated to 190 | reflect the new change. These changes are also automatically re-rendered by `PlaybackMetadataComponent`. 191 | 192 | ## Authentication 193 | All Sonos Control API calls require an access token (See https://devdocs.sonos.com/docs/authorize for more details). In the Sample App, 194 | this access token is saved in the window's local storage and accessed throughout the application, often through a JSON object named `museClientConfig`. 195 | Since the access token is saved, refreshing the page or navigating to other sites does not clear the token. The access token expires after 24 hours, but its 196 | corresponding refresh token does not expire. Clicking the sample app's logout button will clear the currently stored access token and initiate the login process from scratch. 197 | 198 | Authentication flow diagram 199 | 200 | 201 | #### There are three possible access token states a user can encounter when using the sample app: 202 | - `DOES NOT EXIST`: Occurs when sample app is used for the first time, window storage is cleared, or logout button has been clicked 203 | - Login page is displayed, and a new access token is obtained when the user completes the login process 204 | - Access token state is set to `VALID` 205 | - `EXPIRED`: Occurs when access token has been last retrieved more than 24 hours ago 206 | - Access token is updated using the stored refresh token 207 | - If refresh is successful, access token state is set to `VALID`. Otherwise, state is set to `DOES NOT EXIST` 208 | - `VALID`: Occurs when access token has been last retrieved less than 24 hours ago 209 | - Households page is displayed 210 | 211 | These authentication states are checked using `getAccessTokenState` in [authentication.js](sample-app/Client/src/App/Authentication/authentication.js). See 212 | [routingController.jsx](sample-app/Client/src/App/Controllers/routingController.jsx) for the specific conditional rendering used to account for these three states. 213 | 214 | #### Obtaining a new access token 215 | >**Please avoid obtaining a new access token on the client-side in a production setting. See [Sonos Developer Documentation](https://docs.sonos.com/docs/authorize) for best practices.** 216 | 217 | 1. The user clicks the "Log In" button and is redirected to the Sonos login page 218 | 2. The user logs into their Sonos account and authorizes the control integration API key specified in [config.json](sample-app/Client/src/config.json) 219 | 3. Once the login is completed, Sonos provides a response code within the URL parameters. The sample app retrieves this response code 220 | 4. Using this response code, the sample app makes a Sonos API request to generate an access token 221 | 5. This access token response, containing the access token, the time until expiration, and a refresh token, is saved to the window's local storage 222 | 223 | See [oAuthController.jsx](sample-app/Client/src/App/Controllers/oAuthController.jsx) for the entire process and [createAuthToken.jsx](sample-app/Client/src/App/Authentication/createAuthToken.js) 224 | for the specific Sonos API call used for obtaining the access token. 225 | 226 | #### Refreshing an access token 227 | 1. The refresh token of the currently stored access token is retrieved from the window's local storage 228 | 2. A Sonos API call to refresh the access token is executed, with the refresh token encoded and sent in the data of the request 229 | 3. The response of the Sonos API call is used to update the stored access token 230 | 231 | See [refreshAuthToken.js](sample-app/Client/src/App/Authentication/refreshAuthToken.js) 232 | 233 | ## Login/Households Page 234 | #### http://localhost:3000/ 235 | - If no API access token is found, login page is displayed 236 | - If there is a saved access token or login successfully generates an access token, a list of households is fetched from Sonos API and displayed as buttons 237 | - For each household button, the list of players in the household is fetched from the Sonos API and displayed on the household's button 238 | - When the user clicks on a household's button, they are taken to that household's groups page 239 | #### Component Sequence: 240 | - [`RouteComponents`](sample-app/Client/src/App/Controllers/routingController.jsx) is the root component for the login/households page. See [Authentication](#authentication) 241 | for more information on the authentication component sequence. 242 | - If login is complete or if an access token already existed, [`FetchHouseholds`](sample-app/Client/src/App/Controllers/fetchHouseholdsController.jsx) is rendered, 243 | which calls [`GetHouseholds`](sample-app/Client/src/App/UserDetails/getHouseholds.js) to fetch the list of households from the Sonos API 244 | - When the list of households has been fetched, [`ListHouseholdsComponent`](sample-app/Client/src/App/Components/listHouseholdsComponent.jsx) is rendered, which 245 | returns a [`HouseholdRoutingController`](sample-app/Client/src/App/Controllers/householdRoutingController.jsx) component for each household 246 | - [`HouseholdRoutingController`](sample-app/Client/src/App/Controllers/householdRoutingController.jsx) calls the Sonos API for a list of players in the household 247 | and displays a button containing the name of the household and players in the household, and when clicked, the button routes the user to the groups page for that household 248 | 249 | ## Groups Page 250 | #### http://localhost:3000/households/{householdID} 251 | - On instantiation, fetches list of groups in selected household from Sonos API and displays each group as a button 252 | - Subscribes to selected household's group change events, so the page is automatically re-rendered to reflect any group changes 253 | - When the user clicks on a group's button, they are taken to that group's group playback page 254 | #### Component Sequence: 255 | - [`RouteHousehold`](sample-app/Client/src/App/Routing/routeHousehold.js) is the root component for the groups page. It retrieves the household information from 256 | the current location and renders [`FetchGroupsControllerWrapper`](sample-app/Client/src/App/Controllers/fetchGroupsControllerWrapper.js). 257 | - [`FetchGroupsControllerWrapper`](sample-app/Client/src/App/Controllers/fetchGroupsControllerWrapper.js) passes the household's group information to 258 | [`FetchGroups`](sample-app/Client/src/App/Controllers/fetchGroupsController.jsx), which fetches the current household's groups from the Sonos API using 259 | [`GetGroups`](sample-app/Client/src/App/UserDetails/getGroups.js), subscribes to the household's group events with 260 | [`GroupsSubscribe`](sample-app/Client/src/App/UserDetails/groupsSubscribe.js), and then calls [`ListGroupsComponent`](sample-app/Client/src/App/Components/listGroupsComponent.jsx) 261 | - [`ListGroupsComponent`](sample-app/Client/src/App/Components/listGroupsComponent.jsx) returns a [`GroupRoutingController`](sample-app/Client/src/App/Controllers/groupRoutingController.jsx) 262 | for each group 263 | - [`GroupRoutingController`](sample-app/Client/src/App/Controllers/groupRoutingController.jsx) renders a button that displays the group's name, and when clicked, 264 | the button routes the user to that group's group playback page 265 | 266 | ## Group Playback Page 267 | #### http://localhost:3000/groups/{groupID} 268 | - On instantiation, playback state, group volume, playback metadata, group state, current household's groups, and grouped players' volumes are fetched 269 | - Displays current playback information and below, a dropdown menu with "Players" as the default selected option. The "Players" option uses the household's 270 | groups data to display all players in the current household, with a checkbox next to each. This checkbox is checked if the player is in the currently 271 | selected group, and if checked, that player's volume slider is shown 272 | - Dropdown menu's other two options are "Favorites" and "Playlists", which fetch a list of all favorites and playlists in the current household, respectively, 273 | and displays each as a button 274 | - Fetches music service logos from [XML URL](https://service-catalog.ws.sonos.com/mslogo) and uses music service provider ID obtained from playback metadata 275 | Sonos API call to display correct logo 276 | - Subscribes to the following event types and automatically re-renders the page to update the following aspects: 277 | - Playback: Play/pause button state, shuffle button state, repeat button state 278 | - Playback metadata: Track name, container name, artist name, track cover art, and music service provider logo 279 | - Group volume: Group volume slider 280 | - Group status: Group name. If group disappears (GROUP_STATUS_GONE event), user is automatically navigated back to groups page 281 | - Household groups: List of players under "Players" dropdown menu selection. Checkboxes and player volume sliders are updated to reflect which players are in selected group 282 | - Player volume: Player volume sliders of grouped players when "Players" dropdown menu option is selected 283 | - Sonos API controls for group and grouped players: 284 | - On click, play/pause button toggles play/pause for current group. See [`PlaybackStateButton`](sample-app/Client/src/App/Components/GroupSubComponents/playbackStateButton.jsx) 285 | - On click, skip back button restarts current track if possible. On two clicks within 4 seconds, skip back button skips to previous track. 286 | See [`GroupPlaybackComponent`](sample-app/Client/src/App/Components/groupPlaybackComponent.jsx) 287 | - On click, skip next button skips to next track if possible. See [`GroupPlaybackComponent`](sample-app/Client/src/App/Components/groupPlaybackComponent.jsx) 288 | - On click, repeat button cycles through repeat/repeat 1/no repeat if possible. See [`GroupPlaybackComponent`](sample-app/Client/src/App/Components/groupPlaybackComponent.jsx) 289 | - On click, shuffle button toggles shuffle for current group. See [`GroupPlaybackComponent`](sample-app/Client/src/App/Components/groupPlaybackComponent.jsx) 290 | - On change, group volume slider sends volume command for current group. See [`VolumeComponent`](sample-app/Client/src/App/Components/GroupSubComponents/volumeComponent.jsx) 291 | - Clicking an unchecked player groups that player to current group. Clicking a checked player ungroups that player. See 292 | [`PlayerComponent`](sample-app/Client/src/App/Components/playerComponent.jsx) 293 | - On change, player volume slider sends volume command for specific player. See [`PlayerComponent`](sample-app/Client/src/App/Components/playerComponent.jsx) 294 | - On click, a favorite/playlist button loads its favorite/playlist to the current group. See [`FavoriteComponent`](sample-app/Client/src/App/Components/favoriteComponent.jsx) 295 | and [`PlaylistComponent`](sample-app/Client/src/App/Components/playlistComponent.jsx) 296 | 297 | #### Component Sequence 298 | - [`RouteGroup`](sample-app/Client/src/App/Routing/routeGroup.js) is the root component for the group playback page. It retrieves the selected group ID and 299 | household ID from the current location and renders [`GroupPlaybackComponentWrapper`](sample-app/Client/src/App/Components/groupPlaybackComponentWrapper.js) 300 | - [`GroupPlaybackComponentWrapper`](sample-app/Client/src/App/Components/groupPlaybackComponentWrapper.js) passes the selected group's status, playback state, 301 | and selected household's groups state through props to [`GroupPlaybackComponent`](sample-app/Client/src/App/Components/groupPlaybackComponent.jsx) 302 | - [`GroupPlaybackComponent`](sample-app/Client/src/App/Components/groupPlaybackComponent.jsx): 303 | - Calls [`Subscribe`](sample-app/Client/src/App/UserDetails/subscribe.js) to subscribe to all group events and 304 | [`GroupsSubscribe`](sample-app/Client/src/App/UserDetails/groupsSubscribe.js) to subscribe to the household's group change events 305 | - Calls [`GetGroups`](sample-app/Client/src/App/UserDetails/getGroups.js) to fetch the initial state of which players are in the selected group 306 | - Renders each of the group playback subcomponents ([`PlaybackMetaDataComponentWrapper`](sample-app/Client/src/App/Components/GroupSubComponents/playbackMetaDataComponentWrapper.js), 307 | [`PlayBackStateButtonWrapper`](sample-app/Client/src/App/Components/GroupSubComponents/playbackStateButtonWrapper.js), and [`VolumeComponentWrapper`](sample-app/Client/src/App/Components/GroupSubComponents/volumeComponent.jsx)) 308 | - Depending on the dropdown menu selection, one of the following three components is rendered: 309 | - [`PlayersController`](sample-app/Client/src/App/Controllers/playersController.jsx) 310 | - For each player, [`PlayerComponent`](sample-app/Client/src/App/Components/playerComponent.jsx) is returned through 311 | [`PlayerComponentWrapper`](sample-app/Client/src/App/Components/playerComponentWrapper.js) 312 | - Each [`PlayerComponent`](sample-app/Client/src/App/Components/playerComponent.jsx) fetches its initial volume state with 313 | [`GetPlayerVolume`](sample-app/Client/src/App/ControlAPIs/getPlayerVolume.js) and subscribes to volume changes with 314 | [`PlayerVolumeSubscribe`](sample-app/Client/src/App/UserDetails/playerVolumeSubscribe.js) 315 | - Each [`PlayerComponent`](sample-app/Client/src/App/Components/playerComponent.jsx) displays its grouping checkbox and its volume slider if the checkbox is checked 316 | - [`FavoritesController`](sample-app/Client/src/App/Controllers/favoritesController.jsx) 317 | - Calls [`GetFavorites`](sample-app/Client/src/App/UserDetails/getFavorites.js) on instantiation, which fetches a list of the household's favorites from 318 | the Sonos API 319 | - Once favorites are fetched, for each favorite, a [`FavoriteComponent`](sample-app/Client/src/App/Components/favoriteComponent.jsx) is returned, which 320 | displays a button 321 | - [`PlaylistsController`](sample-app/Client/src/App/Controllers/playlistsController.jsx) 322 | - Calls [`GetPlaylists`](sample-app/Client/src/App/UserDetails/getPlaylists.js) on instantiation, which fetches a list of the household's playlists from 323 | the Sonos API 324 | - Once playlists are fetched, for each playlist, a [`PlaylistComponent`](sample-app/Client/src/App/Components/playlistComponent.jsx) is returned, which 325 | displays a button 326 | -------------------------------------------------------------------------------- /gitignorefiles/README.md: -------------------------------------------------------------------------------- 1 | # ignore files 2 | A selection of ignore files for your repository. Adapted from [this baserepo](https://github.com/Sonos-Inc/pdsw-ops-baserepo) 3 | 4 | # file use: 5 | move the .ignore file you want to the top of your repository and rename it as .ignore -------------------------------------------------------------------------------- /gitignorefiles/gradle.ignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /build/ 3 | 4 | # Ignore Gradle GUI config 5 | gradle-app.setting 6 | 7 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 8 | !gradle-wrapper.jar 9 | 10 | # Cache of project 11 | .gradletasknamecache 12 | 13 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 14 | # gradle/wrapper/gradle-wrapper.properties 15 | -------------------------------------------------------------------------------- /gitignorefiles/java.ignore: -------------------------------------------------------------------------------- 1 | # Compiled class file 2 | *.class 3 | 4 | # Log file 5 | *.log 6 | 7 | # BlueJ files 8 | *.ctxt 9 | 10 | # Mobile Tools for Java (J2ME) 11 | .mtj.tmp/ 12 | 13 | # Package Files # 14 | *.jar 15 | *.war 16 | *.ear 17 | *.zip 18 | *.tar.gz 19 | *.rar 20 | 21 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 22 | hs_err_pid* 23 | -------------------------------------------------------------------------------- /gitignorefiles/jboss.ignore: -------------------------------------------------------------------------------- 1 | jboss/server/all/deploy/project.ext 2 | jboss/server/default/deploy/project.ext 3 | jboss/server/minimal/deploy/project.ext 4 | jboss/server/all/log/*.log 5 | jboss/server/all/tmp/**/* 6 | jboss/server/all/data/**/* 7 | jboss/server/all/work/**/* 8 | jboss/server/default/log/*.log 9 | jboss/server/default/tmp/**/* 10 | jboss/server/default/data/**/* 11 | jboss/server/default/work/**/* 12 | jboss/server/minimal/log/*.log 13 | jboss/server/minimal/tmp/**/* 14 | jboss/server/minimal/data/**/* 15 | jboss/server/minimal/work/**/* 16 | 17 | # deployed package files # 18 | 19 | *.DEPLOYED 20 | -------------------------------------------------------------------------------- /gitignorefiles/node.ignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | -------------------------------------------------------------------------------- /gitignorefiles/saas.ignore: -------------------------------------------------------------------------------- 1 | .sass-cache/ 2 | *.css.map 3 | -------------------------------------------------------------------------------- /gitignorefiles/wordpress.ignore: -------------------------------------------------------------------------------- 1 | *.log 2 | wp-config.php 3 | wp-content/advanced-cache.php 4 | wp-content/backup-db/ 5 | wp-content/backups/ 6 | wp-content/blogs.dir/ 7 | wp-content/cache/ 8 | wp-content/upgrade/ 9 | wp-content/uploads/ 10 | wp-content/wp-cache-config.php 11 | wp-content/plugins/hello.php 12 | 13 | /.htaccess 14 | /license.txt 15 | /readme.html 16 | /sitemap.xml 17 | /sitemap.xml.gz 18 | -------------------------------------------------------------------------------- /sample-app/Client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /sample-app/Client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | 3 | # Working dir 4 | WORKDIR /app 5 | 6 | # Copy files from Build 7 | COPY package*.json ./ 8 | 9 | # Install Files 10 | RUN npm install --registry=https://registry.npmjs.org/ 11 | 12 | # Copy SRC 13 | COPY . . 14 | 15 | # Open Port 16 | EXPOSE 3000 17 | 18 | # Docker Command to Start Service 19 | CMD [ "npm", "start" ] -------------------------------------------------------------------------------- /sample-app/Client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sample-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.10.0", 7 | "@emotion/styled": "^11.10.0", 8 | "@mui/material": "^5.10.0", 9 | "@testing-library/jest-dom": "^5.16.4", 10 | "@testing-library/user-event": "^13.5.0", 11 | "axios": "^0.27.2", 12 | "babel-plugin-transform-remove-strict-mode": "^0.0.2", 13 | "bootstrap": "^4.1.1", 14 | "concurrently": "^7.2.2", 15 | "isomorphic-fetch": "^3.0.0", 16 | "lodash": "^4.17.21", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0", 19 | "react-router-dom": "^6.3.0", 20 | "react-scripts": "5.0.1", 21 | "react-select": "^5.7.4", 22 | "reactstrap": "^9.1.1", 23 | "recoil": "^0.7.7", 24 | "socket.io-client": "^4.5.1", 25 | "url": "^0.11.0", 26 | "web-vitals": "^2.1.4" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "build": "react-scripts build", 31 | "test": "react-scripts test", 32 | "eject": "react-scripts eject" 33 | }, 34 | "eslintConfig": { 35 | "extends": [ 36 | "react-app", 37 | "react-app/jest" 38 | ] 39 | }, 40 | "browserslist": { 41 | "production": [ 42 | ">0.2%", 43 | "not dead", 44 | "not op_mini all" 45 | ], 46 | "development": [ 47 | "last 1 chrome version", 48 | "last 1 firefox version", 49 | "last 1 safari version" 50 | ] 51 | }, 52 | "devDependencies": { 53 | "@testing-library/react": "^13.3.0", 54 | "buffer": "^6.0.3", 55 | "eslint-plugin-react-hooks": "^4.6.0", 56 | "jest-dom": "^4.0.0", 57 | "react-test-renderer": "^18.2.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /sample-app/Client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | Sample App - SONOS 12 | 16 | 20 | 21 | 22 | 23 | 24 |
25 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /sample-app/Client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /sample-app/Client/run.sh: -------------------------------------------------------------------------------- 1 | npm start --prefix sample-app/Server 2 | npm start --prefix sample-app/Client -------------------------------------------------------------------------------- /sample-app/Client/src/App/Authentication/authentication.js: -------------------------------------------------------------------------------- 1 | import {Component} from "react"; 2 | 3 | /** 4 | * Class component that can check if locally stored access token is valid and retrieve the stored access token 5 | */ 6 | class Authentication extends Component { 7 | /** 8 | * Checks if access token exists and if so, checks if it is expired or still valid 9 | * @returns {string} "DOES NOT EXIST", "VALID", or "EXPIRED" 10 | */ 11 | getAccessTokenState = () => { 12 | // Retrieves stored access token 13 | let accessToken = window.localStorage.accessToken; 14 | 15 | // If access token does not exist, user will have to log in to Sonos account 16 | if (!accessToken) { 17 | return "DOES NOT EXIST"; 18 | } else { 19 | // Checks if access token is expired 20 | let curTime = Math.floor(Date.now() / 1000); 21 | accessToken = JSON.parse(accessToken); 22 | if (!this.checkAccessTokenExpired(curTime, accessToken)) { 23 | // User can continue to households page 24 | return "VALID"; 25 | } else { 26 | // Access token needs to be refreshed 27 | return "EXPIRED"; 28 | } 29 | } 30 | }; 31 | 32 | /** 33 | * Checks if current access token is valid 34 | * @return {boolean} True if access token is valid, false otherwise 35 | */ 36 | isAccessTokenValid = () => { 37 | return this.getAccessTokenState() === "VALID"; 38 | } 39 | 40 | /** 41 | * Retrieves and returns access token from local storage 42 | * @returns {JSON} Access token 43 | */ 44 | getAccessToken = () => { 45 | return JSON.parse(window.localStorage.accessToken).token; 46 | }; 47 | 48 | /** 49 | * Uses current timestamp, access token's timestamp, and access token time until expiration to check if token is expired 50 | */ 51 | checkAccessTokenExpired(curTime, accessToken) { 52 | return curTime - accessToken.tokenTimestamp >= accessToken.expiry; 53 | } 54 | } 55 | 56 | export default Authentication; 57 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Authentication/createAuthToken.js: -------------------------------------------------------------------------------- 1 | import config from "../../config.json"; 2 | import Helper from "../Utility/helper"; 3 | 4 | /** 5 | * Functional component that fetches access token from Sonos API using previously retrieved code 6 | * @param props.code {string} Code retrieved from logging into Sonos account 7 | * @param props.isLoggedInHandler Handler function that updates access token in oAuthController and status in RouteComponents 8 | */ 9 | export default function CreateAuthToken(props) { 10 | // Helper class contains various helper methods for use throughout the application 11 | const helper = new Helper(); 12 | 13 | // URL to fetch new auth token 14 | let endPoint = config.apiEndPoints.createRefreshAuthTokenURL; 15 | 16 | // Header containing encoded clientId and secret 17 | const headers = helper.getHeadersBasic(); 18 | 19 | // Calls Sonos API and updates access token value 20 | const data = { 21 | grant_type: "authorization_code", 22 | code: props.code, 23 | redirect_uri: helper.getRedirectURL(), 24 | }; 25 | const dataKeyVal = Object.keys(data) 26 | .map((key, index) => `${key}=${encodeURIComponent(data[key])}`) 27 | .join("&"); 28 | helper.apiCall(endPoint, headers, "POST", dataKeyVal).then((authResponse) => { 29 | props.isLoggedInHandler(true, authResponse.data); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Authentication/refreshAuthToken.js: -------------------------------------------------------------------------------- 1 | import Helper from "../Utility/helper"; 2 | import config from "../../config.json"; 3 | 4 | /** 5 | * Functional component used to retrieve a new access token when access token has become invalid 6 | * @param props.accessTokenHandler Handler function from RouteComponents that updates status of access token 7 | */ 8 | export default function RefreshAccessToken(props) { 9 | // Uses Helper apiCall function to call Sonos API 10 | const helper = new Helper(); 11 | 12 | // Gets access token's refresh token from local storage 13 | let refreshToken = JSON.parse( 14 | window.localStorage.accessToken 15 | ).refresh_token; 16 | let endPoint = config.apiEndPoints.createRefreshAuthTokenURL; 17 | 18 | // Calls Sonos API to request fresh access token 19 | const HEADER_BASIC = helper.getHeadersBasic(); 20 | const data = { 21 | grant_type: "refresh_token", 22 | refresh_token: refreshToken, 23 | }; 24 | const dataKeyValue = Object.keys(data) 25 | .map((key, index) => `${key}=${encodeURIComponent(data[key])}`) 26 | .join("&"); 27 | helper.apiCall(endPoint, HEADER_BASIC, "POST", dataKeyValue) 28 | .then((refreshTokenResponse) => { 29 | // If access token is successfully retrieved, update the value of the currently stored token and 30 | // notify RouteComponents that the access token is valid 31 | if (refreshTokenResponse !== undefined) { 32 | updateAccessToken(refreshTokenResponse.data); 33 | props.accessTokenHandler("VALID"); 34 | } 35 | }) 36 | .catch(function (error) { 37 | // If access token is not successfully retrieved, notify RouteComponents that the user needs to log in 38 | console.error(error); 39 | props.accessTokenHandler("DOES NOT EXIST"); 40 | }); 41 | }; 42 | 43 | /** 44 | * Sets the stored value of the Sonos API access token 45 | * @param response Sonos API response when requesting a refreshed access token 46 | */ 47 | function updateAccessToken(response) { 48 | if (response) { 49 | const accessTokenData = { 50 | token: response["access_token"], 51 | refresh_token: response["refresh_token"], 52 | token_type: response["token_type"], 53 | expiry: response["expires_in"], 54 | tokenTimestamp: Math.floor(Date.now() / 1000), 55 | }; 56 | 57 | // Can be accessed from other pages within the sample app 58 | window.localStorage.setItem( 59 | "accessToken", 60 | JSON.stringify(accessTokenData) 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Components/GroupSubComponents/imageComponent.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | /** 4 | * Class component that returns and displays an image based on src and alt provided through props 5 | */ 6 | class ImageComponent extends Component { 7 | render() { 8 | return ( 9 | {this.props.alt} 15 | ); 16 | } 17 | } 18 | 19 | export default ImageComponent; 20 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Components/GroupSubComponents/playbackMetaDataComponent.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Component } from "react"; 3 | import PlayBackMetadata from "../../ControlAPIs/playbackMetadata"; 4 | import ImageComponent from "./imageComponent"; 5 | import ServiceLogoComponent from "./serviceLogoComponent"; 6 | 7 | /** 8 | * Class component for displaying track metadata (track name, track image, artist, container) 9 | */ 10 | class PlaybackMetaDataComponent extends Component { 11 | /** 12 | * @param props.state {JSON} Accesses playbackMetadataAtom's state 13 | * @param props.setState Modifies playbackMetadataAtom's state 14 | * @param props.groupId {string} Used to target specific group when fetching current playback metadata from Sonos API 15 | * @param props.museClientConfig {JSON} Contains access token for Sonos API call 16 | */ 17 | constructor(props) { 18 | super(props); 19 | this.volumeSlider = React.createRef(); 20 | 21 | // Resets playbackMetadataAtom to initial state 22 | // getPlaybackMetaDataFlag = true ensures that new metadata is fetched on instantiation 23 | this.props.setState({ 24 | getPlayBackMetaDataFlag: true, 25 | trackName: null, 26 | trackImage: null, 27 | artistName: null, 28 | containerName: null, 29 | serviceId: null, 30 | serviceName: null 31 | }); 32 | } 33 | 34 | /** 35 | * If track image does not exist, a generic placeholder image is used. Otherwise, the currently stored track image is returned 36 | * @returns {string} Track image src 37 | */ 38 | getImage = () => { 39 | if (!this.props.state.trackImage) { 40 | return require("../../../images/sonos.png"); 41 | } else { 42 | return this.props.state.trackImage; 43 | } 44 | }; 45 | 46 | render() { 47 | return ( 48 |
49 | {/* 50 | Fetches current playback metadata on playbackMetaDataComponent instantiation (only when getPlaybackMetaDataFlag is true) 51 | PlaybackMetadata sets getPlaybackMetaDataFlag to false upon completion 52 | */} 53 | {this.props.state.getPlayBackMetaDataFlag && ( 54 | 58 | )} 59 | 60 | {/* Displays current track information based on the state of playbackMetadataAtom */} 61 |
62 |
63 | 64 |
65 |
{this.props.state.trackName}
66 |
{this.props.state.artistName}
67 |
{this.props.state.containerName}
68 | 69 |
70 |
71 | ); 72 | } 73 | } 74 | 75 | export default PlaybackMetaDataComponent; 76 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Components/GroupSubComponents/playbackMetaDataComponentWrapper.js: -------------------------------------------------------------------------------- 1 | import { useRecoilState } from "recoil"; 2 | import playbackMetadataAtom from "../../Recoil/playbackMetadataAtom"; 3 | import React from "react"; 4 | import PlaybackMetaDataComponent from "./playbackMetaDataComponent"; 5 | 6 | /** 7 | * Wrapper functional component for PlaybackMetaDataComponent class 8 | * Allows PlaybackMetaDataComponent to access and modify the state of playbackMetadataAtom 9 | * Necessary because useRecoilState() (a hook) cannot be called inside a class component 10 | * @param props.groupId {string} Used to target specific group when fetching current playback metadata from Sonos API 11 | * @param props.museClientConfig {JSON} Contains access token for Sonos API call 12 | * @returns {JSX.Element} PlaybackMetaDataComponent 13 | */ 14 | export default function PlaybackMetaDataComponentWrapper(props) { 15 | // playbackMetadataState accesses the state of playbackMetadataAtom, setPlaybackMetadataState modifies the state of playbackMetadataAtom 16 | const [playbackMetadataState, setPlaybackMetadataState] = useRecoilState(playbackMetadataAtom); 17 | return (); 23 | } 24 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Components/GroupSubComponents/playbackStateButton.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Component } from "react"; 3 | import GetPlaybackState from "../../ControlAPIs/getPlaybackState"; 4 | import HelperControls from "../../ControlAPIs/playerControls"; 5 | 6 | /** 7 | * Class component for play/pause button 8 | */ 9 | class PlayBackStateButton extends Component { 10 | /** 11 | * @param props.state {JSON} Accesses state of playbackStateAtom 12 | * @param props.setState Modifies state of playbackStateAtom 13 | * @param props.groupId {string} Used to target current group in Sonos API calls 14 | * @param props.museClientConfig {JSON} Contains access token for Sonos API calls 15 | */ 16 | constructor(props) { 17 | super(props); 18 | 19 | // Used for Sonos API calls 20 | this.ControlOptions = new HelperControls(); 21 | 22 | this.playpauseBtn = React.createRef(); 23 | 24 | // getStateFlag = true ensures that current playback state is fetched on instantiation 25 | this.props.setState({ 26 | isPlaying: false, 27 | getStateFlag: true, 28 | canSkip: false, 29 | canSkipBack: false, 30 | canSeek: false 31 | }); 32 | } 33 | 34 | /** 35 | * Causes play symbol to show if paused and pause symbol to show if playing 36 | * @return {string} className of play or pause symbol 37 | */ 38 | playModeClass = () => { 39 | const playClass = "fa fa-play-circle fa-5x"; 40 | const pauseClass = "fa fa-pause-circle fa-5x"; 41 | return this.props.state.isPlaying ? pauseClass : playClass; 42 | }; 43 | 44 | /** 45 | * onClick handler for play/pause button 46 | * Calls Sonos API to toggle playback for current group 47 | */ 48 | toggleMusic = () => { 49 | this.ControlOptions.helperControls( 50 | "playback/togglePlayPause", 51 | this.props.groupId, 52 | {} 53 | ); 54 | this.setState({ 55 | isPlaying: !this.props.state.isPlaying, 56 | getStateFlag: this.props.state.getStateFlag 57 | }); 58 | this.playModeClass(); 59 | }; 60 | 61 | render() { 62 | return ( 63 | // On instantiation, fetches current playback state from Sonos API. Displays play/pause button 64 |
65 |
66 | {this.props.state.getStateFlag && ( 67 | 68 | )} 69 |
70 | 71 |
72 | 73 |
74 |
75 | ); 76 | } 77 | } 78 | 79 | export default PlayBackStateButton; 80 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Components/GroupSubComponents/playbackStateButtonWrapper.js: -------------------------------------------------------------------------------- 1 | import PlaybackStateButton from "./playbackStateButton"; 2 | import { useRecoilState } from "recoil"; 3 | import playbackStateAtom from "../../Recoil/playbackStateAtom"; 4 | import React from "react"; 5 | 6 | /** 7 | * Wrapper functional component for PlaybackStateButton class 8 | * Allows PlaybackStateButton to access and modify the state of playbackStateAtom 9 | * Necessary because useRecoilState() (a hook) cannot be called inside a class component 10 | * @param props.groupId {string} Used to target specific group when fetching current playback state from Sonos API 11 | * @param props.museClientConfig {JSON} Contains access token for Sonos API call 12 | * @returns {JSX.Element} PlaybackStateButton 13 | */ 14 | export default function PlaybackStateButtonWrapper(props) { 15 | // playbackState accesses the state of playbackMetadataAtom, setPlaybackState modifies the state of playbackMetadataAtom 16 | const [playbackState, setPlaybackState] = useRecoilState(playbackStateAtom); 17 | return (); 23 | } 24 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Components/GroupSubComponents/serviceLogoComponent.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import GetServiceProviderLogos from "../../UserDetails/getServiceProviderLogos"; 3 | 4 | /** 5 | * Class component that fetches the current list of music service provider logos 6 | * Displays the logo corresponding to the currently playing music service 7 | */ 8 | class ServiceLogoComponent extends Component { 9 | /** 10 | * @param props.serviceId {number} Music service provider ID of currently playing song from playbackMetadataAtom. Used to display specific music service logo 11 | */ 12 | constructor(props) { 13 | super(props); 14 | 15 | // fetchFlag = true ensures new data is fetched on instantiation 16 | // logos is a JSON object with each attribute a music service ID and each value a corresponding logo image src URL 17 | this.state = {fetchFlag: true, logos: {}}; 18 | } 19 | 20 | /** 21 | * Handler function that updates fetchFlag and logos in GetServiceProviderLogos 22 | * @param data {JSON} Each attribute is a music service ID and each value is a corresponding logo image src URL 23 | */ 24 | serviceProviderLogosHandler = (data) => { 25 | this.setState({fetchFlag: false, logos: data}); 26 | } 27 | 28 | render() { 29 | return ( 30 |
31 | {/* On instantiation, fetches new logos data and sets fetchFlag to false */} 32 | {this.state.fetchFlag && ( 33 | 36 | )} 37 | 38 | {/* Once data has been fetched, if an image exists for the current music service ID, that image is displayed */} 39 | {!this.state.fetchFlag && this.state.logos.hasOwnProperty(this.props.serviceId) && ( 40 |
41 | {this.props.serviceName} 47 |
48 | )} 49 |
50 | ); 51 | } 52 | } 53 | 54 | export default ServiceLogoComponent; 55 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Components/GroupSubComponents/volumeComponent.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Component } from "react"; 3 | import GetGroupVolume from "../../ControlAPIs/getGroupVolume"; 4 | import SetVolume from "../../ControlAPIs/setVolume"; 5 | import HelperControls from "../../ControlAPIs/playerControls"; 6 | import {debounce} from "lodash"; 7 | 8 | /** 9 | * Class component for group volume control 10 | */ 11 | class VolumeComponent extends Component { 12 | /** 13 | * @param props.state {JSON} Accesses volumeAtom's state 14 | * @param props.setState Modifies volumeAtom's state 15 | * @param props.groupID {string} Current group ID 16 | * @param props.museClientConfig {JSON} Contains Sonos API access key and configuration 17 | */ 18 | constructor(props) { 19 | super(props); 20 | this.volumeSlider = React.createRef(); 21 | 22 | // Used for Sonos API calls 23 | this.ControlOptions = new HelperControls(); 24 | 25 | // getStartVolumeFlag = true ensures that volume state is fetched from Sonos API upon instantiation 26 | this.props.setState({ 27 | volumeVal: 0, 28 | getStartVolumeFlag: true, 29 | }); 30 | } 31 | 32 | /** 33 | * Calls Sonos API to set group volume after volume slider value hasn't changed for 300ms 34 | * Stops user from spamming Sonos API, which also reduces number of volume events received and helps prevent volume slider 35 | * from attempting to update while user is still changing it 36 | * @type {DebouncedFunc} 37 | */ 38 | debouncedSetVolume = debounce(volume => SetVolume(volume, this.props.groupId, "GROUP", this.props.museClientConfig), 300); 39 | 40 | /** 41 | * onChange handler for volume slider. Updates volumeAtom's (and volume slider's) state and calls Sonos API to set group volume 42 | */ 43 | onSetVolume = () => { 44 | const volume = this.volumeSlider.current.value; 45 | this.props.setState({ 46 | volumeVal: volume, 47 | getStartVolumeFlag: false 48 | }); 49 | this.debouncedSetVolume(volume); 50 | }; 51 | 52 | render() { 53 | // On instantiation, calls GetGroupVolume, which fetches volume state from Sonos API 54 | // Creates volume slider used to control and display group volume. Volume slider value is volumeAtom's volumeVal attribute 55 | return ( 56 |
57 | {this.props.state.getStartVolumeFlag && ( 58 | 62 | )} 63 | 64 | 65 | 75 | 76 |
77 | ); 78 | } 79 | } 80 | 81 | export default VolumeComponent; 82 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Components/GroupSubComponents/volumeComponentWrapper.js: -------------------------------------------------------------------------------- 1 | import VolumeComponent from "./volumeComponent"; 2 | import { useRecoilState } from "recoil"; 3 | import volumeAtom from "../../Recoil/volumeAtom"; 4 | import React from "react"; 5 | 6 | /** 7 | * Wrapper functional component for VolumeComponent class 8 | * Allows VolumeComponent to access and modify the state of volumeAtom 9 | * Necessary because useRecoilState() (a hook) cannot be called inside a class component 10 | * @param props.groupId {string} Used to target specific group when fetching current group volume from Sonos API 11 | * @param props.museClientConfig {JSON} Contains access token for Sonos API call 12 | * @returns {JSX.Element} VolumeComponent 13 | */ 14 | export default function VolumeComponentWrapper(props) { 15 | // volumeState accesses the state of volumeAtom, setVolumeAtom modifies the state of volumeAtom 16 | const [volumeState, setVolumeState] = useRecoilState(volumeAtom); 17 | return (); 23 | } 24 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Components/backButtonComponent.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | /** 4 | * The BackButton() function is used to navigate backwards the same way a browser back button works 5 | * This function is passed through props. 6 | * @param props.navigate Used to navigate to previous page. Result of useNavigate() call 7 | */ 8 | export default function BackButton(props) { 9 | /** 10 | * onClick handler for back button. Navigates user to previous page 11 | */ 12 | const goBack = () => { 13 | props.navigate(-1); 14 | } 15 | 16 | return ( 17 |
18 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Components/favoriteComponent.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Component } from "react"; 3 | import HelperControls from "../ControlAPIs/playerControls"; 4 | import {Container} from "reactstrap"; 5 | 6 | /** 7 | * Class component for a single Sonos favorite 8 | * Displays button and loads favorite on click 9 | */ 10 | class FavoriteComponent extends Component { 11 | /** 12 | * @param props.state {JSON} Favorite information, including name and ID 13 | * @param props.groupId {string} Used to target current group when calling Sonos API 14 | */ 15 | constructor(props) { 16 | super(props); 17 | 18 | // Used for Sonos API calls 19 | this.ControlOptions = new HelperControls(); 20 | } 21 | 22 | /** 23 | * onClick handler that calls Sonos API to load favorite to currently displayed group 24 | */ 25 | playFavoriteHandler = () => { 26 | const data = { favoriteId: this.props.state.id } 27 | this.ControlOptions.helperControls("favorites", this.props.groupId, data); 28 | } 29 | 30 | render() { 31 | // Returns button that displays favorite name and when clicked, loads favorite to current group 32 | return ( 33 | 40 | ); 41 | } 42 | } 43 | 44 | export default FavoriteComponent; 45 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Components/groupPlaybackComponent.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Component } from "react"; 3 | 4 | import HelperControls from "../ControlAPIs/playerControls"; 5 | import Subscribe from "../UserDetails/subscribe"; 6 | import PlaybackMetaDataComponentWrapper from "./GroupSubComponents/playbackMetaDataComponentWrapper"; 7 | import PlayBackStateButtonWrapper from "./GroupSubComponents/playbackStateButtonWrapper"; 8 | import VolumeComponentWrapper from "./GroupSubComponents/volumeComponentWrapper"; 9 | import PlayersController from "../Controllers/playersController"; 10 | import HeaderComponent from "./headerComponent"; 11 | import GroupGoneRoutingController from "../Controllers/groupGoneRoutingController"; 12 | import BackButton from "./backButtonComponent" 13 | import GetGroups from "../UserDetails/getGroups"; 14 | import GroupsSubscribe from "../UserDetails/groupsSubscribe"; 15 | import FavoritesController from "../Controllers/favoritesController"; 16 | import Select from "react-select"; 17 | import PlaylistsController from "../Controllers/playlistsController"; 18 | 19 | /** 20 | * This page contains all the components on the group playback page 21 | * Contains players in current household, group volume slider, players volume slider, and the back button to the groups page 22 | * @param props.householdId {string} targets specific household in Sonos API 23 | * @param props.museClientConfig {JSON} Contains access token for Sonos API call 24 | * @param props.groupId {string} targets specific group when fetching current playback state from Sonos API 25 | * @param props.playback {JSON} Accesses the state of playbackStateAtom 26 | * @param props.groupsInfoState {JSON} Accesses state of groupsInfoAtom 27 | */ 28 | class GroupPlaybackComponent extends Component { 29 | constructor(props) { 30 | super(props); 31 | 32 | // Used for Sonos API calls 33 | this.ControlOptions = new HelperControls(); 34 | 35 | // Keeps track of when the "skip to previous" button was last clicked 36 | this.lastClickTime = Date.now(); 37 | 38 | // Keeps track of which menu option to display (players, favorites, or playlists). Displays players by default 39 | this.state = {menuState:"PLAYERS"}; 40 | 41 | // Dropdown menu options 42 | this.options = [ 43 | { value: "PLAYERS", label: "Players"}, 44 | { value: "FAVORITES", label: "Favorites"}, 45 | { value: "PLAYLISTS", label: "Playlists"} 46 | ]; 47 | } 48 | render() { 49 | return ( 50 |
51 | {/* Subscribes to the current group's playback state, volume, and playback metadata */} 52 | 56 | 57 |
58 | {/* 59 | Subscribes to groups events for the current household 60 | + Any groups change events received cause this component to be re-rendered with the new information 61 | */} 62 | 63 |
64 | 65 | {/* Upon instantiation, fetches groups information from Sonos API and sets groupFlag to false */} 66 | {this.props.groupsInfoState.groupFlag && ( 67 | 74 | )} 75 | 76 | {/* When currently displayed group disappears, user is navigated back to groups page */} 77 | {this.props.state.groupGoneFlag && ( 78 | 81 | )} 82 | 83 | 84 | 85 |
86 |
87 |

{this.props.state.groupName}

88 |
89 |
90 | 91 |
92 | {/* Fetches playback metadata from Sonos API and displays. Updates in response to events */} 93 | 97 | 98 |
99 | {/* If repeat or repeatOne is available, repeat/repeatOne button is shown 100 | If repeat is enabled, repeat button is shown 101 | If repeatOne is enabled, repeatOne button is shown 102 | If both repeat and repeatOne are disabled, a light gray repeat button is shown */} 103 |
104 | {(this.props.playback.canRepeat || this.props.playback.canRepeatOne) && (!this.props.playback.repeatOne 105 | ? (Repeat) 106 | : (Repeat One) 107 | )} 108 |
109 | 110 | {/* If current playback cannot skip to previous and cannot restart, skipToPrevious button appears disabled */} 111 |
112 | 113 |
114 | 115 | {/* Fetches playback state from Sonos API and displays play/pause button. Updates in response to events */} 116 | 120 | 121 | {/* If current playback cannot skip to next, skipToNext button appears disabled */} 122 |
123 | 124 |
125 | 126 | {/* If shuffle is available, shuffle button is displayed. If shuffle is enabled, button has darker opacity */} 127 |
128 | {this.props.playback.canShuffle && ( 129 | Shuffle 130 | )} 131 |
132 |
133 | 134 |

Group Volume:

135 | {/* Fetches current group's volume state from Sonos API and displays volume slider. Volume slider value updates in response to events */} 136 | 140 |
141 | 142 |
143 | {/* Dropdown menu that allows user to choose whether to see a list of players, favorites, or playlists */} 144 | 115 | {this.props.playerName} 116 | 117 |
118 | 119 | {/* Player volume slider shows if player is in current group. Slider value updates corresponding to playerVolumeAtomFamily Atom's state */} 120 | {this.props.inGroup && ( 121 |
122 | 123 | 133 | 134 |
135 | )} 136 |
137 |
138 | ); 139 | } 140 | } 141 | 142 | export default PlayerComponent; 143 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Components/playerComponentWrapper.js: -------------------------------------------------------------------------------- 1 | import { useRecoilState } from "recoil"; 2 | import React from "react"; 3 | import playerVolumeAtomFamily from "../Recoil/playerVolumeAtomFamily"; 4 | import PlayerComponent from "./playerComponent"; 5 | 6 | /** 7 | * Wrapper functional component for PlayerComponent class 8 | * Allows PlayerComponent to access and modify the state of an atom in playerVolumeAtomFamily 9 | * Necessary because useRecoilState() (a hook) cannot be called inside a class component 10 | * @param props.playerId {string} Used to target specific player in Sonos API calls and in playerVolumeAtomFamily 11 | * @param props.playerName {string} Name of player displayed 12 | * @param props.group {JSON} Information of currently displayed group 13 | * @param props.museClientConfig {JSON} Contains access token for Sonos API calls 14 | * @param props.inGroup {boolean} True if player is in current group, false otherwise. Obtained from groupsInfoAtom 15 | * @returns {JSX.Element} PlayerComponent 16 | */ 17 | export default function PlayerComponentWrapper(props) { 18 | // volState accesses the state of playerVolumeAtomFamily atom, setVolState modifies the state of playerVolumeAtomFamily atom 19 | const [volState, setVolState] = useRecoilState(playerVolumeAtomFamily(props.playerId)); 20 | return (); 30 | } 31 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Components/playlistComponent.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Component } from "react"; 3 | import HelperControls from "../ControlAPIs/playerControls"; 4 | import {Container} from "reactstrap"; 5 | 6 | /** 7 | * Class component for a single Sonos playlist 8 | * Displays button and loads playlist on click 9 | */ 10 | class PlaylistComponent extends Component { 11 | /** 12 | * @param props.state {JSON} Playlist information, including name and ID 13 | * @param props.groupId {string} Used to target current group when calling Sonos API 14 | */ 15 | constructor(props) { 16 | super(props); 17 | 18 | // Used for Sonos API calls 19 | this.ControlOptions = new HelperControls(); 20 | } 21 | 22 | /** 23 | * onClick handler that calls Sonos API to load playlist for currently displayed group 24 | */ 25 | loadPlaylistHandler = () => { 26 | const data = { playlistId: this.props.state.id } 27 | this.ControlOptions.helperControls("playlists", this.props.groupId, data); 28 | } 29 | 30 | render() { 31 | // Returns button that displays playlist name and when clicked, loads playlist to current group 32 | return ( 33 | 40 | ); 41 | } 42 | } 43 | 44 | export default PlaylistComponent; 45 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/ControlAPIs/getGroupVolume.js: -------------------------------------------------------------------------------- 1 | import {GroupVolumeApiFactory} from "../museClient/api"; 2 | import VolumeHandler from "../MuseDataHandlers/VolumeHandler"; 3 | import volumeAtom from "../Recoil/volumeAtom"; 4 | import {useRecoilState} from "recoil"; 5 | 6 | /** 7 | * Fetches group volume from Sonos API and sets state of volumeAtom 8 | * @param props.museClientConfig {JSON} Contains access token for Sonos API call 9 | * @param props.groupId {string} Used to target specific group when calling Sonos API 10 | */ 11 | export default function GetGroupVolume(props) { 12 | // volumeState (unused) accesses and setVolumeState modifies volumeAtom's state 13 | const [volumeState, setVolumeState] = useRecoilState(volumeAtom); 14 | 15 | // Used to make group volume Sonos API calls with our access token and configuration 16 | const groupVolumeApi = new GroupVolumeApiFactory(props.museClientConfig); 17 | 18 | // Fetches current group volume from Sonos API, processes response through VolumeHandler, and sets volumeAtom's state 19 | groupVolumeApi 20 | .groupVolumeGetVolume(props.groupId) 21 | .then((res) => { 22 | setVolumeState(VolumeHandler(res)); 23 | }) 24 | .catch(function (error) { 25 | console.error("Error", error.message); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/ControlAPIs/getPlaybackState.js: -------------------------------------------------------------------------------- 1 | import { PlaybackApi } from "../museClient/api"; 2 | import PlaybackStateHandler from "../MuseDataHandlers/PlaybackStateHandler"; 3 | import {useRecoilState} from "recoil"; 4 | import playbackStateAtom from "../Recoil/playbackStateAtom"; 5 | 6 | /** 7 | * Fetches current playback state from Sonos API and sets playbackStateAtom's state 8 | * @param props.museClientConfig {JSON} Contains access token for Sonos API call 9 | * @param props.groupId Used to target specific group when calling Sonos API 10 | */ 11 | export default function GetPlaybackState(props) { 12 | // playbackStateResponse (unused) accesses and setPlaybackStateResponse modifies playbackStateAtom's state 13 | const [playbackStateResponse, setPlaybackStateResponse] = useRecoilState(playbackStateAtom); 14 | 15 | // Used to make playback Sonos API calls with our access token and configuration 16 | const playBackApi = new PlaybackApi(props.museClientConfig); 17 | 18 | // Fetches current playback state from Sonos API, processes response through PlaybackStateHandler, and sets playbackStateAtom's state 19 | playBackApi 20 | .playbackGetPlaybackStatus(props.groupId) 21 | .then((res) => { 22 | setPlaybackStateResponse(PlaybackStateHandler(res)); 23 | }) 24 | .catch(function (error) { 25 | console.error("Error in fetching the state at start: ", error); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/ControlAPIs/getPlayerVolume.js: -------------------------------------------------------------------------------- 1 | import {PlayerVolumeApiFactory} from "../museClient/api"; 2 | import VolumeHandler from "../MuseDataHandlers/VolumeHandler"; 3 | import {useRecoilState} from "recoil"; 4 | import playerVolumeAtomFamily from "../Recoil/playerVolumeAtomFamily"; 5 | 6 | /** 7 | * Fetches player volume from Sonos API and sets state of Atom in playerVolumeAtomFamily 8 | * @param props.museClientConfig {JSON} Contains access token for Sonos API call 9 | * @param props.playerId {string} Used to target specific player when calling Sonos API 10 | */ 11 | export default function GetPlayerVolume(props) { 12 | // volumeState (unused) accesses and setVolumeState modifies the relevant Atom in playerVolumeAtomFamily's state 13 | const [volumeState, setVolumeState] = useRecoilState(playerVolumeAtomFamily(props.playerId)); 14 | 15 | // Used to make player volume Sonos API calls with our access token and configuration 16 | const playerVolumeApi = new PlayerVolumeApiFactory(props.museClientConfig); 17 | 18 | // Fetches current player volume from Sonos API, processes response through VolumeHandler, and sets playerVolumeAtomFamily Atom's state 19 | playerVolumeApi 20 | .playerVolumeGetVolume(props.playerId) 21 | .then((res) => { 22 | const data = VolumeHandler(res); 23 | setVolumeState(data); 24 | }) 25 | .catch(function (error) { 26 | console.error("Error", error.message); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/ControlAPIs/playbackMetadata.js: -------------------------------------------------------------------------------- 1 | import { PlaybackMetadataApiFactory } from "../museClient/api"; 2 | import PlaybackMetadataHandler from "../MuseDataHandlers/PlaybackMetadataHandler"; 3 | import {useRecoilState} from "recoil"; 4 | import playbackMetadataAtom from "../Recoil/playbackMetadataAtom"; 5 | 6 | /** 7 | * Fetches current playback metadata from Sonos API and sets playbackMetadataAtom's state 8 | * @param props.museClientConfig {JSON} Contains access token for Sonos API call 9 | * @param props.groupId {string} Used to target specific group when calling Sonos API 10 | */ 11 | export default function PlayBackMetadata(props) { 12 | // playbackMetadataResponse (unused) accesses and setPlaybackMetadataResponse modifies playbackMetadataAtom's state 13 | const [playbackMetadataResponse, setPlaybackMetadataResponse] = useRecoilState(playbackMetadataAtom); 14 | 15 | // Used to make playback metadata Sonos API calls with currently stored access token and configuration 16 | const playBackMetadataApi = new PlaybackMetadataApiFactory(props.museClientConfig); 17 | 18 | // Fetches current playback metadata from Sonos API, processes response through PlaybackMetadataHandler, and sets playbackMetadataAtom's state 19 | playBackMetadataApi 20 | .playbackMetadataGetMetadataStatus(props.groupId) 21 | .then((res) => { 22 | setPlaybackMetadataResponse(PlaybackMetadataHandler(res)); 23 | }) 24 | .catch(function (error) { 25 | console.error("Error", error); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/ControlAPIs/playerControls.js: -------------------------------------------------------------------------------- 1 | import Helper from "../Utility/helper"; 2 | import { Component } from "react"; 3 | 4 | /** 5 | * Helper class used to make player control Sonos API calls 6 | */ 7 | class HelperControls extends Component { 8 | constructor() { 9 | super(); 10 | 11 | // Used to make API calls 12 | this.helper = new Helper(); 13 | } 14 | 15 | /** 16 | * Calls apiCall() from ../Utility/helper.js using the specified control action, group, and data 17 | * @param input_action {string} Specific control action to execute 18 | * @param groupId {string} Group ID to target in API call 19 | * @param data {JSON} Body of API call 20 | */ 21 | helperControls(input_action, groupId, data) { 22 | let endPoint = 23 | this.helper.getGroupsURL() + groupId + "/" + input_action; 24 | 25 | // Contains access token and API response format specifier 26 | const headers = this.helper.getHeaderBearer(); 27 | 28 | // Executes API call 29 | this.helper 30 | .apiCall(endPoint, headers, "POST", data) 31 | .then((res) => { 32 | return true; 33 | }) 34 | .catch(function (error) { 35 | console.error(error); 36 | return false; 37 | }); 38 | } 39 | } 40 | 41 | export default HelperControls; 42 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/ControlAPIs/setVolume.js: -------------------------------------------------------------------------------- 1 | import { GroupVolumeApiFactory } from "../museClient/api"; 2 | import { PlayerVolumeApiFactory } from "../museClient/api"; 3 | 4 | /** 5 | * Calls Sonos API to set the volume of a group or player 6 | * @param volume {number} Volume value from 0 to 100 7 | * @param targetId {string} Group ID or player ID to target in API call 8 | * @param targetType {string} "GROUP" or "PLAYER" to specify which API call to make 9 | * @param museClientConfig {JSON} Contains access token for Sonos API call 10 | */ 11 | export default function SetVolume(volume, targetId, targetType, museClientConfig) { 12 | // Body of API call 13 | const data = { volume: volume }; 14 | 15 | if (targetType === "GROUP") { 16 | // Used to make group volume Sonos API calls with our access token and configuration 17 | const groupVolumeApi = new GroupVolumeApiFactory(museClientConfig); 18 | 19 | // Executes Sonos API call to specified group 20 | groupVolumeApi 21 | .groupVolumeSetVolume(targetId, data) 22 | .catch(function (error) { 23 | console.error("Error", error); 24 | }); 25 | } else { 26 | // Used to make player volume Sonos API calls with our access token and configuration 27 | const playerVolumeApi = new PlayerVolumeApiFactory(museClientConfig); 28 | 29 | // Executes Sonos API call to specified player 30 | playerVolumeApi 31 | .playerVolumeSetVolume(targetId, data) 32 | .catch(function (error) { 33 | console.error("Error", error); 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Controllers/favoritesController.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Component } from "react"; 3 | import GetFavorites from "../UserDetails/getFavorites"; 4 | import FavoriteComponent from "../Components/favoriteComponent"; 5 | 6 | /** 7 | * Class component that fetches and displays list of all Sonos favorites in selected household 8 | */ 9 | class FavoritesController extends Component { 10 | /** 11 | * @param props.museClientConfig {JSON} Contains Sonos API access token and configuration 12 | * @param props.householdId {string} Used to target current household when fetching favorites 13 | * @param props.groupId {string} Used to target current group when loading favorites 14 | */ 15 | constructor(props) { 16 | super(props); 17 | 18 | // fetchFlag = true causes favorites to be fetched on instantiation 19 | this.state = {fetchFlag: true, favorites: []}; 20 | } 21 | 22 | /** 23 | * Handler function that updates the array of favorites in this.state 24 | * @param favorites {Array} Array of JSON objects each containing the information of a favorite in current household 25 | */ 26 | favoritesHandler = (favorites) => { 27 | // Fetch flag = false stops fetching of favorites from Sonos API 28 | this.setState({fetchFlag: false, favorites:favorites}); 29 | } 30 | 31 | render() { 32 | return ( 33 |
34 | {/* Upon instantiation, fetches favorites from Sonos API and sets fetchFlag to false */} 35 | {this.state.fetchFlag && ( 36 | )} 41 | 42 | {/* Once favorites have been fetched, a button is created for each favorite */} 43 | {!this.state.fetchFlag && ( 44 | this.state.favorites.map((item) => { 45 | return () 50 | }) 51 | )} 52 |
53 | ); 54 | } 55 | } 56 | 57 | export default FavoritesController; 58 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Controllers/fetchGroupsController.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Component } from "react"; 3 | import GetGroups from "../UserDetails/getGroups"; 4 | import ListGroupsComponent from "../Components/listGroupsComponent"; 5 | import GroupsSubscribe from "../UserDetails/groupsSubscribe"; 6 | 7 | /** 8 | * Class component that fetches and displays list of all groups in selected household 9 | */ 10 | class FetchGroups extends Component { 11 | /** 12 | * @param props.setGroupsInfoState Modifies state of groupsInfoAtom, which contains information on groups, players, and household ID 13 | * @param props.groupsInfoState {JSON} Accesses state of groupsInfoAtom 14 | * @param props.householdId {string} Used to target specific household in Sonos API calls 15 | * @param props.museClientConfig {JSON} Contains Sonos API access token 16 | */ 17 | constructor(props) { 18 | super(props); 19 | // Default value for groupFlag causes groups to be fetched on instantiation 20 | this.props.setGroupsInfoState({ 21 | groupFlag: true, 22 | groups: null, 23 | players: null 24 | }); 25 | } 26 | render() { 27 | return ( 28 |
29 |
30 | {/* Upon instantiation, fetches groups information from Sonos API and sets groupFlag to false */} 31 | {this.props.groupsInfoState.groupFlag && ( 32 | 38 | )} 39 |
40 |
41 | {/* 42 | Subscribes to groups events for the current household 43 | Any groups change events received cause this component to be re-rendered with the new information 44 | */} 45 | 46 |
47 |
48 | {/* Once groups information has been fetched, a button that routes user to group playback page is created for each group */} 49 | {!this.props.groupsInfoState.groupFlag && ( 50 | 56 | )} 57 |
58 |
59 | ); 60 | } 61 | } 62 | 63 | export default FetchGroups; 64 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Controllers/fetchGroupsControllerWrapper.js: -------------------------------------------------------------------------------- 1 | import { useRecoilState } from "recoil"; 2 | import React from "react"; 3 | import groupsInfoAtom from "../Recoil/groupsInfoAtom"; 4 | import FetchGroups from "./fetchGroupsController"; 5 | import {useNavigate} from "react-router-dom"; 6 | 7 | /** 8 | * Wrapper functional component for FetchGroupsController class 9 | * Necessary because useRecoilState() (a hook) cannot be called inside a class component 10 | * useRecoilState() is used to access and modify the state of groupsInfoAtom, which keeps track of all groups and players in selected household 11 | * @param props.householdId {string} Used to target current household in Sonos API calls 12 | * @param props.museClientConfig {JSON} Contains access token and configuration for Sonos API calls 13 | * @returns {JSX.Element} FetchGroups class component 14 | */ 15 | export default function FetchGroupsControllerWrapper(props) { 16 | // groupsInfoState accesses state and setGroupsInfoState modifies state of groupsInfoAtom 17 | const [groupsInfoState, setGroupsInfoState] = useRecoilState(groupsInfoAtom); 18 | 19 | // Used to route user to group playback and send data to new location 20 | let navigate = useNavigate(); 21 | 22 | // Returns a FetchGroups component with the ability to access and modify groupsInfoAtom through props 23 | return (); 30 | } 31 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Controllers/fetchHouseholdsController.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Component } from "react"; 3 | import { Configuration } from "../museClient/configuration"; 4 | import GetHouseholds from "../UserDetails/getHouseholds"; 5 | import ListHouseholdsComponent from "../Components/listHouseholdsComponent"; 6 | 7 | 8 | /** 9 | * Class component that fetches and displays list of all households associated with user 10 | */ 11 | class FetchHouseholds extends Component { 12 | // Default value for householdFlag causes households to be fetched on instantiation 13 | state = { 14 | householdFlag: true, 15 | households: null, 16 | }; 17 | 18 | /** 19 | * Handler function to update state of FetchHouseholds. Passed through props to GetHouseholds 20 | * @param householdsResponse {Array} List of households fetched from Sonos API 21 | */ 22 | hh_handler = (householdsResponse) => { 23 | this.setState({ 24 | householdFlag: false, 25 | households: householdsResponse, 26 | }); 27 | }; 28 | 29 | render() { 30 | // Contains access token needed for Sonos API calls 31 | const museClientConfig = new Configuration({ 32 | accessToken: JSON.parse(window.localStorage.accessToken).token, 33 | }); 34 | 35 | // First calls GetHouseholds, which updates this.state to contain a list of households associated with user and sets state.householdFlag to false 36 | // GetHouseholds then is unmounted and ListHouseholdsComponent is instantiated, which displays a button for each household 37 | return ( 38 |
39 |
40 | {this.state.householdFlag && ( 41 | 45 | )} 46 |
47 |
48 | {!this.state.householdFlag && ( 49 | 50 | )} 51 |
52 |
53 | ); 54 | } 55 | } 56 | 57 | export default FetchHouseholds; 58 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Controllers/groupGoneRoutingController.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from "react"; 2 | 3 | /** 4 | * Navigates back to previous page. Intended use is when the currently displayed group disappears (GROUP_STATUS_GONE event) 5 | * This functional component is necessary, as useEffect cannot be called within a class component 6 | * @param props.navigate The result of calling the useNavigate() hook in GroupPlaybackComponentWrapper 7 | */ 8 | export default function GroupGoneRoutingController(props) { 9 | useEffect(() => { 10 | props.navigate(-1); 11 | }, []); 12 | } 13 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Controllers/groupRoutingController.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { useNavigate } from "react-router-dom"; 4 | import { Container } from "reactstrap"; 5 | import {useRecoilState} from "recoil"; 6 | import selectedGroupAtom from "../Recoil/selectedGroupAtom"; 7 | 8 | /** 9 | * Returns a button that when clicked, routes user to the appropriate group playback 10 | * @param props.group {JSON} Contains group information 11 | * @param props.householdID {string} Current household ID 12 | * @returns {JSX.Element} Group button 13 | */ 14 | export default function GroupRoutingController(props) { 15 | // Used to change currently displayed path and send data to new path 16 | let navigate = useNavigate(); 17 | 18 | // groupStatusState (unused) accesses and setGroupStatusState modifies the state of groupStatusAtom 19 | const [selectedGroupState, setSelectedGroupState] = useRecoilState(selectedGroupAtom); 20 | 21 | /** 22 | * onClick listener of button. Updates groupStatusAtom and navigates to group's path 23 | */ 24 | const routeChange = () => { 25 | // Path to navigate to for current group 26 | let path = "../groups/" + props.group.id; 27 | 28 | // Updates the state of selectedGroupAtom to values of this button's group 29 | setSelectedGroupState({ 30 | groupId: props.group.id, 31 | groupName: props.group.name, 32 | groupGoneFlag: false 33 | }); 34 | 35 | // Navigates to new path for current group, sending data for the group ID and household ID along with 36 | const data = { 37 | state: { 38 | householdId: props.householdId, 39 | groupId: props.group.id 40 | }, 41 | }; 42 | navigate(path, data); 43 | }; 44 | 45 | // Returns button with routeChange as onClick listener 46 | return ( 47 |
48 | 49 | 50 |

{props.group.name}

51 |
52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Controllers/householdRoutingController.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { useNavigate } from "react-router-dom"; 4 | import { Container } from "reactstrap"; 5 | import { GroupsApiFactory } from "../museClient/api"; 6 | import { useState, useEffect } from "react"; 7 | 8 | /** 9 | * Returns a button that when clicked, routes user to the appropriate household's groups page 10 | * @param props.household {JSON} Contains household information 11 | * @param props.index {number} Used for naming the household, as most households do not have names 12 | * @returns {JSX.Element} Household button 13 | */ 14 | export default function HouseholdRoutingController(props) { 15 | // Used to change currently displayed path and send data to new path 16 | let navigate = useNavigate(); 17 | 18 | //When set to false, list of players is rendered 19 | const [fetchFlag, setFetchFlag] = useState(true); 20 | 21 | // Stores list of players fetched from Sonos API 22 | const [players, setPlayers] = useState([]); 23 | 24 | useEffect(() => { 25 | // Used to make groups Sonos API calls with currently stored access token and configuration 26 | const groupsApi = new GroupsApiFactory(props.museClientConfig); 27 | 28 | // Fetches current groups from Sonos API 29 | groupsApi.groupsGetGroups(props.household.id) 30 | .then((groupsApiResponse) => { 31 | setPlayers(groupsApiResponse.players); 32 | setFetchFlag(false); 33 | }) 34 | .catch(function (error) { 35 | // Error in fetching data from Sonos API. 36 | console.error("Error", error); 37 | }); 38 | }, []); 39 | 40 | /** 41 | * onClick listener of button that navigates to household's path and sends household ID information to new location 42 | */ 43 | const routeChange = () => { 44 | let path = "households/" + props.household.id; 45 | const data = { state: { householdId: props.household.id } }; 46 | navigate(path, data); 47 | }; 48 | 49 | // Returns household button with routeChange as onClick listener 50 | return ( 51 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Controllers/oAuthController.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import CreateAuthToken from "../Authentication/createAuthToken"; 3 | import Helper from "../Utility/helper"; 4 | import ImageComponent from "../Components/GroupSubComponents/imageComponent"; 5 | 6 | /** 7 | * Class component that displays login page, which contains a button that when clicked, routes user to Sonos login page 8 | * Upon successful login, access token is generated and passed into the handler function passed through props 9 | * User stays on login page until login is successful 10 | */ 11 | export default class OAuthController extends Component { 12 | /** 13 | * @param props.accessTokenHandler Handler function that updates access token status in RouteComponents 14 | */ 15 | constructor(props) { 16 | super(props); 17 | 18 | // When code is generated, this is set to true and auth token is created 19 | this.codeGeneratedFlag = false; 20 | 21 | // Needed to get access token from Sonos API 22 | this.code = null; 23 | 24 | // Helper class contains various helper methods for use throughout the application 25 | this.helper = new Helper(); 26 | } 27 | 28 | /** 29 | * Gets param code and sets this.code and this.codeGeneratedFlag 30 | * Called before CreateAuthToken, as CreateAuthToken depends on the param code 31 | */ 32 | getCode = () => { 33 | // Gets param code from current URL for later use in getting access token from Sonos API 34 | // Param code is generated when Sonos login is completed 35 | let cur_url = window.location.href; 36 | const params = new URLSearchParams(cur_url); 37 | const code_generated = params.get("code"); 38 | 39 | // If param code was successfully retrieved, sets code value and sets codeGeneratedFlag to true, causing CreateAuthToken to be called 40 | // Otherwise, signals to routingController that it should retry authentication 41 | if (code_generated !== null) { 42 | this.codeGeneratedFlag = true; 43 | this.code = code_generated; 44 | } else { 45 | this.props.accessTokenHandler("DOES NOT EXIST"); 46 | } 47 | }; 48 | 49 | /** 50 | * Passed through props to CreateAuthToken 51 | * Sets value of access token status in RouteComponents by calling accessTokenHandler function passed through props 52 | * @param flag {boolean} True if user has logged in, false otherwise 53 | * @param response {JSON} Sonos API response from CreateAuthToken 54 | */ 55 | isLoggedInHandler = (flag, response) => { 56 | // Data retrieved from CreateAuthToken that is to be stored in the current window 57 | const accessTokenData = { 58 | token: response["access_token"], 59 | refresh_token: response["refresh_token"], 60 | token_type: response["token_type"], 61 | expiry: response["expires_in"], 62 | tokenTimestamp: Math.floor(Date.now() / 1000), 63 | }; 64 | // Can be accessed from other pages within the sample app 65 | window.localStorage.setItem( 66 | "accessToken", 67 | JSON.stringify(accessTokenData) 68 | ); 69 | 70 | // Updates access token value in RouteComponents 71 | this.props.accessTokenHandler(flag ? "VALID" : "DOES NOT EXIST"); 72 | }; 73 | 74 | render() { 75 | // Gets param code first since it is needed for authentication 76 | this.getCode(); 77 | return ( 78 | // Displays login page 79 |
80 |
81 |
82 | Login page background image 87 |
88 |
89 |

Log into your Sonos account

90 |
91 | {/* Login button that when clicked, takes user to Sonos login URL */} 92 | 93 | 96 | 97 |
98 |
99 | {/* When param code is retrieved, auth token is created and value in RouteComponents is updated */} 100 | {this.codeGeneratedFlag && ( 101 | 106 | )} 107 |
108 |
109 | ); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Controllers/playersController.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Component } from "react"; 3 | import PlayerComponentWrapper from "../Components/playerComponentWrapper"; 4 | 5 | /** 6 | * Class component that displays every player in the current household 7 | * See PlayerComponentWrapper and PlayerComponent for the function of each player 8 | */ 9 | class PlayersController extends Component { 10 | /** 11 | * @param props.players {Array} Array of JSON objects, each of which containing a player's information 12 | * @param props.museClientConfig {JSON} Contains access token needed for Sonos API calls 13 | * @param props.group {JSON} Contains information of current group, including ID and players in group 14 | */ 15 | constructor(props) { 16 | super(props); 17 | } 18 | render() { 19 | // Through PlayerComponentWrapper, creates a PlayerComponent for every player in props.players 20 | const players = this.props.players; 21 | return ( 22 |
23 |
24 | {players.map((item) => { 25 | {/* inGroup is true if current group contains this player */} 26 | return () 34 | })} 35 |
36 | ); 37 | } 38 | } 39 | 40 | export default PlayersController; 41 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Controllers/playlistsController.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Component } from "react"; 3 | import GetPlaylists from "../UserDetails/getPlaylists"; 4 | import PlaylistComponent from "../Components/playlistComponent"; 5 | 6 | /** 7 | * Class component that fetches and displays list of all Sonos playlists in selected household 8 | */ 9 | class PlaylistsController extends Component { 10 | /** 11 | * @param props.museClientConfig {JSON} Contains Sonos API access token and configuration 12 | * @param props.householdId {string} Used to target current household when fetching playlists 13 | * @param props.groupId {string} Used to target current group when loading playlists 14 | */ 15 | constructor(props) { 16 | super(props); 17 | 18 | // fetchFlag = true causes playlists to be fetched on instantiation 19 | this.state = {fetchFlag: true, playlists: []}; 20 | } 21 | 22 | /** 23 | * Handler function that updates the array of playlists in this.state 24 | * @param playlists {Array} Array of JSON objects each containing the information of a playlist in current household 25 | */ 26 | playlistsHandler = (playlists) => { 27 | // Fetch flag = false stops fetching of playlists from Sonos API 28 | this.setState({fetchFlag: false, playlists:playlists}); 29 | } 30 | 31 | render() { 32 | return ( 33 |
34 | {/* Upon instantiation, fetches playlists from Sonos API and sets fetchFlag to false */} 35 | {this.state.fetchFlag && ( 36 | )} 41 | 42 | {/* Once playlists have been fetched, a button is created for each playlist */} 43 | {!this.state.fetchFlag && ( 44 | this.state.playlists.map((item) => { 45 | return () 50 | }) 51 | )} 52 |
53 | ); 54 | } 55 | } 56 | 57 | export default PlaylistsController; 58 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Controllers/routingController.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Component } from "react"; 3 | import Authentication from "../Authentication/authentication"; 4 | import FetchHouseholds from "./fetchHouseholdsController"; 5 | import OAuthController from "./oAuthController"; 6 | import RefreshAccessToken from "../Authentication/refreshAuthToken"; 7 | 8 | /** 9 | * When navigating to http://localhost:3000, user is taken to this component 10 | * If user is already logged in, households page is displayed 11 | * Otherwise, access token is refreshed or login page is displayed 12 | */ 13 | class RouteComponents extends Component { 14 | constructor(props) { 15 | super(props); 16 | 17 | // Checks local storage for access token. If token is expired, authStatus is "EXPIRED", 18 | // If token does not exist, authStatus is "DOES NOT EXIST". Otherwise, authStatus is "VALID" 19 | this.state = {authStatus: new Authentication().getAccessTokenState()}; 20 | } 21 | 22 | /** 23 | * Handler function passed through props to OAuthController and RefreshAccessToken. Updates state.authStatus 24 | * @param status {string} New value for state.authStatus 25 | */ 26 | accessTokenHandler = (status) => { 27 | this.setState({ authStatus: status}); 28 | }; 29 | 30 | render() { 31 | // If access token is expired, refresh access token 32 | // If access token does not exist, display login page 33 | // If access token is valid, continue to households display 34 | return ( 35 |
36 | {this.state.authStatus === "EXPIRED" && ( 37 | 40 | )} 41 | {this.state.authStatus === "DOES NOT EXIST" && ( 42 | 45 | )} 46 | {this.state.authStatus === "VALID" && } 47 |
48 | ); 49 | } 50 | } 51 | 52 | export default RouteComponents; 53 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/MuseDataHandlers/GroupsInfoHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Function that converts raw Sonos API groups response and into usable format for groupsInfoAtom 3 | * See https://devdocs.sonos.com/reference/groups-getgroups for Sonos API response example 4 | * @param requestData {JSON} Sonos API response for getGroups or current household's groups events 5 | * @returns {{players: Array, groups: JSON, groupFlag: boolean}} Array of all players in current household, 6 | * JSON object with each attribute corresponding to a group ID, boolean value is false because groups information is retrieved 7 | */ 8 | export default function GroupsInfoHandler(requestData) { 9 | try { 10 | // Sonos API response contains an array of groups, which would make retrieving the information for a specific group difficult without storing its index 11 | // Each group contains the attribute playerIds, which is an array of string player IDs. Checking if a player is in a group using this would be O(N) 12 | // Original format: groups: [ {id: string, playerIds: [ string, string, ...], ...}, ...] 13 | 14 | // New format: groups: { (a group's ID): {id: string, playersInGroup: { (a player's ID): string, ...} ...} 15 | // Instead of an Array, new format for groups is a JSON object where each attribute is a group ID and each value is that group's information 16 | // Within each group, the playersInGroup field is a JSON object where each attribute is a player ID and each value is also the player ID 17 | // Allows for O(1) retrieval of group information with just a group ID and O(1) checking of if a player is in a specified group 18 | const groups = {}; 19 | requestData.groups.forEach(group => groups[group.id] = group); 20 | requestData.groups.forEach(group => groups[group.id].playersInGroup = {}); 21 | requestData.groups.forEach(group => group.playerIds.forEach(player => groups[group.id].playersInGroup[player] = player)); 22 | 23 | // State of groupsInfoAtom will be set to this return value 24 | return { 25 | groupFlag: false, 26 | groups: groups, 27 | players: requestData.players 28 | } 29 | } catch (e) { 30 | console.error("Error in fetching the group status from the event", e); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/MuseDataHandlers/PlaybackMetadataHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Function that converts raw Sonos API playback metadata response into usable format for playbackMetadataAtom 3 | * See https://devdocs.sonos.com/reference/playbackmetadata-getmetadatastatus for Sonos API response example 4 | * @param requestData {JSON} Sonos API response for getMetadataStatus or metadata event 5 | * @returns {{containerName: string, trackImage: string, artistName: string, trackName: string, getPlayBackMetaDataFlag: boolean, 6 | * serviceId: number, serviceName: string}} 7 | * Playback container name, track image url, artist name, track name. Boolean getPlaybackMetaDataFlag always false since metadata has been retrieved 8 | * serviceId used to display music service provider logo, serviceName used for logo image alt 9 | */ 10 | export default function PlaybackMetadataHandler(requestData) { 11 | try { 12 | // Sets trackName, artistName, and containerName blank if value in API response does not exist 13 | const trackName = requestData.currentItem?.track?.name 14 | ? requestData.currentItem.track.name 15 | : " "; 16 | const artistName = requestData.currentItem?.track?.artist?.name 17 | ? requestData.currentItem.track.artist.name 18 | : " "; 19 | const containerName = requestData.container?.name 20 | ? requestData.container.name 21 | : " "; 22 | 23 | // Uses current item's image if it exists. Otherwise, uses container image if it exists and null if not. 24 | const trackImage = requestData.currentItem?.track?.imageUrl 25 | ? requestData.currentItem.track.imageUrl 26 | : (requestData.container?.imageUrl ? requestData.container.imageUrl : null); 27 | 28 | // Converts Sonos API music service provider format to the service provider logos XML file format 29 | // If service ID value does not exist, sets serviceId to be an invalid ID, causing no logo to be displayed 30 | const serviceId = requestData?.container?.service?.id 31 | ? requestData.container.service.id * 256 + 7 32 | : -1; 33 | 34 | // Music service provider logo image alt is name of music service if service name exists. Otherwise, alt is a generic message 35 | const serviceName = requestData?.container?.service?.name 36 | ? requestData.container.service.name 37 | : "Music service provider"; 38 | 39 | // playbackMetadataAtom is set to equal return value 40 | return { 41 | trackName: trackName, 42 | trackImage: trackImage, 43 | artistName: artistName, 44 | containerName: containerName, 45 | getPlayBackMetaDataFlag: false, 46 | serviceId: serviceId, 47 | serviceName: serviceName 48 | }; 49 | } catch (e) { 50 | console.error("Error in fetching the metadata state from the event", e); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/MuseDataHandlers/PlaybackStateHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Function that converts raw Sonos API playback response into usable format for playbackStateAtom 3 | * See https://devdocs.sonos.com/reference/playback-getplaybackstatus for Sonos API response example 4 | * @param requestData {JSON} Sonos API response for getPlaybackStatus or playback event 5 | * @return {{getStateFlag: boolean, isPlaying: boolean, canSkip: boolean, canSkipBack: boolean, canSeek: boolean}} 6 | * getStateFlag is false since playback state has been retrieved. Boolean values describing the current playback's possible actions 7 | */ 8 | export default function PlaybackStateHandler(requestData) { 9 | try { 10 | // Determines state of play/pause button on group playback page 11 | const playBackState = requestData.playbackState === "PLAYBACK_STATE_PLAYING" || requestData.playbackState === "PLAYBACK_STATE_BUFFERING"; 12 | 13 | // playbackStateAtom is set to equal return value 14 | return { 15 | isPlaying: playBackState, 16 | getStateFlag: false, 17 | canSkip: requestData.availablePlaybackActions.canSkip, 18 | canSkipBack: requestData.availablePlaybackActions.canSkipBack, 19 | canSeek: requestData.availablePlaybackActions.canSeek, 20 | repeat: requestData.playModes.repeat, 21 | repeatOne: requestData.playModes.repeatOne, 22 | shuffle: requestData.playModes.shuffle, 23 | canRepeat: requestData.availablePlaybackActions.canRepeat, 24 | canRepeatOne: requestData.availablePlaybackActions.canRepeatOne, 25 | canShuffle: requestData.availablePlaybackActions.canShuffle 26 | }; 27 | } catch (e) { 28 | console.error("Error in fetching the playback state from the event", e); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/MuseDataHandlers/SelectedGroupHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Function that converts raw Sonos API group status response events into usable format for selectedGroupAtom 3 | * These events are automatically subscribed to when subscribed to any aspect of a group (volume, playback, playback metadata, etc.) 4 | * @param requestData {JSON} Sonos API response for a group status change event (player added, player removed, and/or group name change) 5 | * @return {{groupName: string, groupGoneFlag: boolean}} 6 | * Name of currently displayed group. Boolean groupGoneFlag true if group has disappeared, false otherwise 7 | */ 8 | export default function SelectedGroupHandler(requestData) { 9 | try { 10 | // Group has disappeared. groupGoneFlag = true notifies GroupPlaybackComponent to navigate user back to groups page 11 | if(requestData.groupStatus === "GROUP_STATUS_GONE") { 12 | // selectedGroupAtom is set to equal this return value 13 | return { 14 | groupName: " ", 15 | groupGoneFlag: true, 16 | }; 17 | } else { 18 | // selectedGroupAtom is set to equal this return value 19 | return { 20 | groupName: requestData.groupName, 21 | groupGoneFlag: false, 22 | }; 23 | } 24 | } catch (e) { 25 | console.error("Error in fetching the group status from the event", e); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/MuseDataHandlers/VolumeHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Function that converts raw Sonos API volume response (group or player) into usable format for volumeAtom or playerVolumeAtomFamily 3 | * See https://devdocs.sonos.com/reference/groupvolume-getvolume for Sonos API group volume response example 4 | * @param requestData {JSON} Sonos API response for a group or player volume event or a group or player getVolume call 5 | * @return {{volumeVal: number, getStartVolumeFlag: boolean}} 6 | * volume value for player or group. Boolean getStartVolumeFlag is false since volume has been retrieved 7 | */ 8 | export default function VolumeHandler(requestData) { 9 | try { 10 | // Value of volume slider on group or player component 11 | const volume = requestData.volume; 12 | 13 | // volumeAtom or an atom in playerVolumeAtomFamily is set to equal this return value 14 | return { 15 | getStartVolumeFlag: false, 16 | volumeVal: volume 17 | }; 18 | } catch (e) { 19 | console.error("Error in fetching the volume from the event", e); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Recoil/groupsInfoAtom.js: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil" 2 | 3 | /** 4 | * Recoil atom that keeps track of all groups and players in the current household 5 | * Can be accessed and modified by calling useRecoilState(groupsInfoAtom) 6 | */ 7 | const groupsInfoAtom = atom({ 8 | key: 'groupsInfoAtom', // unique ID (with respect to other atoms/selectors) 9 | default: { 10 | groupFlag: true, // {boolean} If true, getGroups is called and groups information is retrieved from Sonos API 11 | groups: null, // {JSON} Each attribute is a group ID, with its value being that group's information. See GroupsInfoHandler for an in-depth explanation 12 | players: null, // {Array} List of all players in the current household 13 | householdId: null // {string} Current household's household ID. Used to filter incoming events to ensure they are for the correct household 14 | } 15 | }); 16 | 17 | export default groupsInfoAtom; 18 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Recoil/playbackMetadataAtom.js: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil" 2 | 3 | /** 4 | * Recoil atom that keeps track of the playback metadata of the currently displayed group on the group playback page 5 | * Can be accessed and modified by calling useRecoilState(playbackMetadataAtom) 6 | */ 7 | const playbackMetadataAtom = atom({ 8 | key: 'playbackMetadataAtom', // unique ID (with respect to other atoms/selectors) 9 | default: { 10 | getPlayBackMetaDataFlag: true, // {boolean} If true, PlaybackMetadata is called and current metadata is fetched from Sonos API 11 | trackName: null, // {string} Currently playing item's name 12 | trackImage: null, // {string} URL of currently playing item or item's container if current item's image does not exist 13 | artistName: null, // {string} Currently playing item's artist's name 14 | containerName: null, // {string} Currently playing container's name 15 | serviceId: null, // {number} ID of currently playing music service. Used to display service logo 16 | serviceName: null // {string} Name of currently playing music service. Used for service logo image alt 17 | } 18 | }); 19 | 20 | export default playbackMetadataAtom 21 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Recoil/playbackStateAtom.js: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil" 2 | 3 | /** 4 | * Recoil atom that keeps track of the playback state of the currently displayed group on the group playback page 5 | * Can be accessed and modified by calling useRecoilState(playbackStateAtom) 6 | */ 7 | const playbackStateAtom = atom({ 8 | key: 'playbackStateAtom', // unique ID (with respect to other atoms/selectors) 9 | default: { 10 | isPlaying: false, // {boolean} Determines state of play/pause button on group playback page 11 | getStateFlag: true, // {boolean} If true, GetPlaybackState is called and current playback state is fetched from Sonos API 12 | canSkip: false, // {boolean} Determines functionality of skip button 13 | canSkipBack: false, // {boolean} Determines functionality of skip back button 14 | canSeek: false, // {boolean} Determines functionality of skip back button. Seek is needed when restarting track (seek to position 0) 15 | repeat: false, // {boolean} Determines state of repeat button on group playback page 16 | repeatOne: false, // {boolean} Determines state of repeat button on group playback page 17 | shuffle: false, // {boolean} Determines state of shuffle button on group playback page 18 | canRepeat: false, // {boolean} Determines whether repeat button is shown on group playback page 19 | canRepeatOne: false, // {boolean} Determines whether repeat button is shown on group playback page 20 | canShuffle: false // {boolean} Determines whether shuffle button is shown on group playback page 21 | }, 22 | }); 23 | 24 | export default playbackStateAtom 25 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Recoil/playerVolumeAtomFamily.js: -------------------------------------------------------------------------------- 1 | import { atomFamily } from "recoil" 2 | 3 | /** 4 | * Recoil atomFamily that allows for keeping track of the volume states of a variable number of players 5 | * A player's volume state can be accessed and modified by calling useRecoilState(playerVolumeAtomFamily({player ID}) 6 | */ 7 | const playerVolumeAtomFamily = atomFamily({ 8 | key: 'playerVolumeAtomFamily', // unique ID (with respect to other atoms/selectors) 9 | default: { 10 | getStartVolumeFlag: true, // {boolean} If true, getPlayerVolume is called and player volume state is fetched from Sonos API 11 | volumeVal: 0 // {number} Volume value of player. Player volume slider is set to this value 12 | } 13 | }); 14 | 15 | export default playerVolumeAtomFamily; 16 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Recoil/selectedGroupAtom.js: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil" 2 | 3 | /** 4 | * Recoil atom that keeps track of the name and ID of the currently displayed group 5 | * Can be accessed and modified by calling useRecoilState(selectedGroupAtom) 6 | */ 7 | const selectedGroupAtom = atom({ 8 | key: 'selectedGroupAtom', // unique ID (with respect to other atoms/selectors) 9 | default: { 10 | groupName: null, // {string} Name of currently displayed group 11 | groupId: null, // {string} ID of currently displayed group 12 | groupGoneFlag: false // {boolean} If group has disappeared, set to true. If true, user is navigated back to groups page 13 | } 14 | }); 15 | 16 | export default selectedGroupAtom; 17 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Recoil/volumeAtom.js: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil" 2 | 3 | /** 4 | * Recoil atom that keeps track of the currently displayed group's volume state 5 | * Can be accessed and modified by calling useRecoilState(volumeAtom) 6 | */ 7 | const volumeAtom = atom({ 8 | key: 'volumeAtom', // unique ID (with respect to other atoms/selectors) 9 | default: { 10 | volumeVal: 0, // {number} Volume value of currently displayed group. Group volume slider is set to this value 11 | getStartVolumeFlag: true, // {boolean} If true, GetGroupVolume is called and current group's volume status is fetched from Sonos API 12 | }, 13 | }); 14 | 15 | export default volumeAtom 16 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Routing/routeGroup.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useLocation, useNavigate } from "react-router-dom"; 3 | import Authentication from "../Authentication/authentication"; 4 | import { Configuration } from "../museClient/configuration"; 5 | import GroupPlaybackComponentWrapper from "../Components/groupPlaybackComponentWrapper"; 6 | 7 | /** 8 | * Functional component that displays the GroupPlaybackComponent for the selected group 9 | * User is routed to this page after clicking a group button on the groups page 10 | * @return {JSX.Element} GroupPlaybackComponent through GroupPlaybackComponentWrapper 11 | */ 12 | function RouteGroup() { 13 | // Used to route user to back to start page if access token has expired or if routed information has been lost 14 | const navigate = useNavigate(); 15 | 16 | // Retrieves data sent to this path by groupRoutingController 17 | const { state } = useLocation(); 18 | 19 | // If data does not exist or if access token has expired, user is rerouted to start page 20 | useEffect(() => { 21 | if (state === null || state === undefined || !(new Authentication().isAccessTokenValid())) { 22 | navigate("/"); 23 | } 24 | }, []); 25 | 26 | if (state !== null && (new Authentication().isAccessTokenValid())) { 27 | // Retrieves current household ID and selected group ID from current location 28 | const {householdId, groupId} = state; 29 | 30 | // Sets configuration to include access token. Used for Sonos API calls 31 | const museClientConfig = new Configuration({ 32 | accessToken: JSON.parse(window.localStorage.accessToken).token, 33 | }); 34 | 35 | // Returns GroupPlaybackComponent of current group through GroupPlaybackComponentWrapper 36 | return ; 41 | } 42 | } 43 | 44 | export default RouteGroup; 45 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Routing/routeHousehold.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useLocation, useNavigate } from "react-router-dom"; 3 | import Authentication from "../Authentication/authentication"; 4 | import { Configuration } from "../museClient/configuration"; 5 | import FetchGroupsControllerWrapper from "../Controllers/fetchGroupsControllerWrapper"; 6 | 7 | /** 8 | * Functional component that displays the list of groups for the selected household 9 | * User is routed to this page after clicking a household button on the households page 10 | * @return {JSX.Element} Through FetchGroupsControllerWrapper, a button for each group in the selected household 11 | */ 12 | function RouteHousehold() { 13 | // Used to route user to back to start page if access token has expired or if routed information has been lost 14 | const navigate = useNavigate(); 15 | 16 | // Retrieves data sent to this path by householdsRoutingController 17 | const { state } = useLocation(); 18 | 19 | // If data does not exist or if access token has expired, user is rerouted to start page 20 | useEffect(() => { 21 | if (state === null || state === undefined || !(new Authentication().isAccessTokenValid())) { 22 | navigate("/"); 23 | } 24 | }, []); 25 | if (state !== null && (new Authentication().isAccessTokenValid())) { 26 | // Retrieves current household ID from current location 27 | const {householdId} = state; 28 | 29 | 30 | // Sets configuration to include access token. Used for Sonos API calls 31 | const museClientConfig = new Configuration({ 32 | accessToken: JSON.parse(window.localStorage.accessToken).token, 33 | }); 34 | 35 | // Returns FetchGroupsControllerWrapper, which through FetchGroupsController and ListGroupsComponent, displays all groups in the current household 36 | return ; 37 | } 38 | } 39 | 40 | export default RouteHousehold; 41 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/UnitTests/createAuthToken.test.js: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import CreateAuthToken from "../Authentication/createAuthToken"; 3 | import Helper from "../Utility/helper"; 4 | 5 | test("testing the authentication API", () => { 6 | const helper = new Helper(); 7 | const sampleCode = "BX48GHe6"; 8 | render( 9 | 13 | ); 14 | }); 15 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/UnitTests/getGroups.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import GetGroups from "../UserDetails/getGroups"; 3 | import { Configuration } from "../museClient/configuration"; 4 | import testConfig from "./testConfig.json"; 5 | import {RecoilRoot} from 'recoil'; 6 | import '@testing-library/jest-dom'; 7 | 8 | test("testing the getGroups API", () => { 9 | const testHouseholdID = testConfig.householdID; 10 | const testMuseClientConfig = new Configuration({ 11 | accessToken: testConfig.authToken 12 | }); 13 | render( 14 | 15 | 20 | 21 | ); 22 | expect(screen.getByTestId('custom-element')).toBeVisible(); 23 | }); 24 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/UnitTests/getHouseholdID.test.js: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import { Configuration } from "../museClient/configuration"; 3 | import testConfig from "./testConfig.json"; 4 | import GetHouseholds from "../UserDetails/getHouseholds"; 5 | 6 | test("testing the getHouseholds API", () => { 7 | const testMuseClientConfig = new Configuration({ 8 | accessToken: testConfig.authToken 9 | }); 10 | hh_handler = () => { 11 | }; 12 | 13 | render( 14 | 18 | ); 19 | }); -------------------------------------------------------------------------------- /sample-app/Client/src/App/UnitTests/refreshAuthToken.test.js: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import RefreshAuthToken from "../Authentication/refreshAuthToken"; 3 | 4 | test("testing the refresh token API", () => { 5 | 6 | }); 7 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/UnitTests/routeHousehold.test.js: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import OAuthController from "../Controllers/oAuthController"; 3 | 4 | jest.mock("../Controllers/oAuthController"); 5 | 6 | test("testing the getHouseholds API", () => { 7 | OAuthController.mockImplementation(() =>
OAuthController
); 8 | }); -------------------------------------------------------------------------------- /sample-app/Client/src/App/UnitTests/testConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "authToken": "AXbUa0O1KzWw6aP5ADZ26AgnP2KE", 3 | "householdID": "Sonos_tz981tZwEWDpKnAuQeAz2JpAmR.k_Z1z-u37GYZtPrJvDYO" 4 | } 5 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/UserDetails/getFavorites.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { CircularProgress } from '@mui/material'; 3 | import React from "react"; 4 | import { FavoritesApiFactory } from "../museClient/api"; 5 | import HeaderComponent from "../Components/headerComponent"; 6 | 7 | /** 8 | * Fetches current household's favorites from Sonos API 9 | * @param props.favoritesHandler Handler function that updates stored favorites array in FavoritesController 10 | * @param props.museClientConfig {JSON} Contains Sonos API access token and configuration 11 | * @param props.householdId {string} Used to target current household in Sonos API call 12 | */ 13 | export default function GetFavorites(props) { 14 | // error is set to true if error has been encountered. False by default 15 | const [error, setError] = useState(false); 16 | 17 | useEffect(() => { 18 | // Used to make favorites Sonos API calls with currently stored access token and configuration 19 | const FavoritesApi = new FavoritesApiFactory(props.museClientConfig); 20 | 21 | // Fetches current favorites from Sonos API 22 | FavoritesApi.favoritesGetFavorites(props.householdId) 23 | .then((favoritesApiResponse) => { 24 | // Processes API response and updates state in FavoritesController 25 | props.favoritesHandler(favoritesApiResponse.items) 26 | 27 | // No error encountered 28 | setError(false); 29 | }) 30 | .catch(function (error) { 31 | // Error in fetching data from Sonos API. Causes error screen to be displayed 32 | console.error("Error", error); 33 | setError(true) 34 | }); 35 | }, []); 36 | 37 | // If an error has occurred, show error screen. Otherwise, show loading symbol while data is being fetched 38 | return error === true ? ( 39 |
40 | 41 |
42 |

Favorites in this household could not be found.

43 |
44 | ) : ( 45 |
46 | 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/UserDetails/getGroups.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { CircularProgress } from '@mui/material'; 3 | import React from "react"; 4 | import { GroupsApiFactory } from "../museClient/api"; 5 | import HeaderComponent from "../Components/headerComponent"; 6 | import {useRecoilState} from "recoil"; 7 | import groupsInfoAtom from "../Recoil/groupsInfoAtom"; 8 | import GroupsInfoHandler from "../MuseDataHandlers/GroupsInfoHandler"; 9 | import selectedGroupAtom from "../Recoil/selectedGroupAtom"; 10 | 11 | /** 12 | * Fetches current household's groups information from Sonos API 13 | * @param props.museClientConfig {JSON} Contains Sonos API access token 14 | * @param props.householdId {string} ID of current household 15 | * @param props.setGroup {boolean} If true, this function updates the state of selectedGroupAtom 16 | * This is false when GetGroups is called by FetchGroupsController, since a group has not yet been selected 17 | * This is true when GetGroups is called by GroupPlaybackComponent, since a group has been selected 18 | * @param props.groupId {string} Only to be passed through props if props.setGroup is true. 19 | * Used to identify which group's data to use when updating selectedGroupAtom 20 | * @param props.displayLoadingScreen {boolean} True when called from FetchGroups, false when called from GroupPlaybackComponent 21 | * @return {JSX.Element} Displays error screen only if error has been encountered 22 | */ 23 | export default function GetGroups(props) { 24 | // groupsInfoState (unused) accesses and setGroupsInfoState modifies groupsInfoAtom's state 25 | const [groupsInfoState, setGroupsInfoState] = useRecoilState(groupsInfoAtom); 26 | 27 | // selectedGroupState (unused) accesses and setSelectedGroupState modifies selectedGroupAtom's state 28 | const [selectedGroupState, setSelectedGroupState] = useRecoilState(selectedGroupAtom); 29 | 30 | // error is set to true if error has been encountered. False by default 31 | const [error, setError] = useState(false); 32 | 33 | useEffect(() => { 34 | // Used to make groups Sonos API calls with currently stored access token and configuration 35 | const groupsApi = new GroupsApiFactory(props.museClientConfig); 36 | 37 | // Fetches current groups from Sonos API 38 | groupsApi.groupsGetGroups(props.householdId) 39 | .then((groupsAPIresponse) => { 40 | // Processes API response and updates state of groupsInfoAtom 41 | const res = GroupsInfoHandler(groupsAPIresponse); 42 | res.householdId = props.householdId 43 | setGroupsInfoState(res); 44 | 45 | // If indicated by props.setGroup, selectedGroupAtom is updated to reflect the state of the group with the ID of props.groupId 46 | if (props.setGroup) { 47 | setSelectedGroupState({ 48 | groupGoneFlag: false, 49 | groupId: props.groupId, 50 | groupName: res.groups[props.groupId].name 51 | }); 52 | } 53 | 54 | // No error encountered 55 | setError(false); 56 | }) 57 | .catch(function (error) { 58 | // Error in fetching data from Sonos API. Causes error screen to be displayed 59 | console.error("Error", error); 60 | setError(true) 61 | }); 62 | }, []); 63 | 64 | // If an error has occurred, show error screen. Otherwise, if on groups page, show loading screen while data is being fetched 65 | return error === true ? ( 66 |
67 | 68 |
69 |

Groups in this household could not be found.

70 |
71 | ) : props.showLoadingScreen && ( 72 |
73 |
74 | 75 |
76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/UserDetails/getHouseholds.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import React from "react"; 3 | import { CircularProgress } from '@mui/material'; 4 | import HeaderComponent from "../Components/headerComponent"; 5 | import { HouseholdsApiFactory } from "../museClient/api"; 6 | 7 | /** 8 | * Gets a list of households from the Sonos API 9 | * @param props.hh_handler Handler function that updates list of households in fetchHouseholdsController 10 | * @returns {JSX.Element} If no households are found, returns display informing user. Otherwise, display loading screen 11 | */ 12 | export default function GetHouseholds(props) { 13 | // error value determines if loading screen or "No device..." screen is returned 14 | const [error, setError] = useState(false); 15 | 16 | useEffect(() => { 17 | // Used to make household Sonos API calls 18 | const householdsApi = new HouseholdsApiFactory(props.museClientConfig); 19 | 20 | // Fetches households from Sonos API, calls handler function to update fetchHouseholdsController 21 | householdsApi.householdsGetHouseholds() 22 | .then((houseHoldsResponse) => { 23 | setError(false); 24 | props.hh_handler(houseHoldsResponse["households"]); 25 | }) 26 | .catch(function (error) { 27 | setError(true); 28 | return Promise.reject(error); 29 | }); 30 | }, []); 31 | 32 | // If an error was encountered with the Sonos API call, display error screen. Otherwise, continue and show loading screen 33 | return error === true ? ( 34 |
35 | 36 |
37 |

No device connected to the network...

38 |
39 | ) : ( 40 |
41 |
42 | 43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/UserDetails/getPlaylists.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { CircularProgress } from '@mui/material'; 3 | import React from "react"; 4 | import {PlaylistsApiFactory} from "../museClient/api"; 5 | import HeaderComponent from "../Components/headerComponent"; 6 | 7 | /** 8 | * Fetches current household's playlists from Sonos API 9 | * @param props.playlistsHandler Handler function that updates stored playlists array in PlaylistsController 10 | * @param props.museClientConfig {JSON} Contains Sonos API access token and configuration 11 | * @param props.householdId {string} Used to target current household in Sonos API call 12 | */ 13 | export default function GetPlaylists(props) { 14 | // error is set to true if error has been encountered. False by default 15 | const [error, setError] = useState(false); 16 | 17 | useEffect(() => { 18 | // Used to make playlists Sonos API calls with currently stored access token and configuration 19 | const PlaylistsApi = new PlaylistsApiFactory(props.museClientConfig); 20 | 21 | // Fetches current playlists from Sonos API 22 | PlaylistsApi.playlistsGetPlaylists(props.householdId) 23 | .then((playlistsApiResponse) => { 24 | // Processes API response and updates state in PlaylistsController 25 | props.playlistsHandler(playlistsApiResponse.playlists) 26 | 27 | // No error encountered 28 | setError(false); 29 | }) 30 | .catch(function (error) { 31 | // Error in fetching data from Sonos API. Causes error screen to be displayed 32 | console.error("Error", error); 33 | setError(true) 34 | }); 35 | }, []); 36 | 37 | // If an error has occurred, show error screen. Otherwise, show loading symbol while data is being fetched 38 | return error === true ? ( 39 |
40 | 41 |
42 |

Playlists in this household could not be found.

43 |
44 | ) : ( 45 |
46 | 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/UserDetails/getServiceProviderLogos.js: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | import config from "../../config.json" 3 | 4 | /** 5 | * Fetches the music service provider logos XML file and converts it into a usable format for ServiceLogoComponent 6 | * Final format is a JSON object with each attribute being a service ID and each value an image src URL 7 | * @param props.serviceProviderLogosHandler Handler function that updates the state.logos JSON object in ServiceLogoComponent 8 | */ 9 | export default function GetServiceProviderLogos(props) { 10 | // Fetches logos in XML format from URL specified in config.json 11 | axios.get(config.serviceLogosURL, {responseType: "document"}).then((response) => { 12 | // JSON object with format {[serviceId]:[image src URL], ...} 13 | let res = {}; 14 | 15 | // Converts response into an array of "service" objects. Each service object contains 6 logo images of differing sizes 16 | const list = Array.from(response.request.responseXML.getElementsByTagName("service")); 17 | 18 | // For each service object, its ID is added to res with the src URL of the 112x112 version of its logo as the value 19 | list.forEach(element => { 20 | let imagesList = Array.from(element.getElementsByTagName("image")); 21 | imagesList.forEach(image => { 22 | if(image.attributes.placement.nodeValue === "BrandLogo-v2" || image.attributes.placement.nodeValue === "square") { 23 | res[element.id] = image.innerHTML; 24 | } 25 | }); 26 | }); 27 | 28 | // Updates the state.logos JSON object in ServiceLogoComponent to equal res 29 | props.serviceProviderLogosHandler(res); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/UserDetails/groupsSubscribe.js: -------------------------------------------------------------------------------- 1 | import Helper from "../Utility/helper"; 2 | import { useEffect } from "react"; 3 | 4 | /** 5 | * Functional component that subscribes to current household's groups events 6 | * Unsubscribes on unmounting of component 7 | * @param props.householdId {string} Used to target current household in Sonos API calls 8 | */ 9 | export default function GroupsSubscribe(props) { 10 | // Used to make API calls 11 | const helper = new Helper(); 12 | 13 | useEffect(() => { 14 | // Groups subscription URL 15 | let endPointGS = helper.getHouseHoldURL() + props.householdId + "/groups/subscription"; 16 | 17 | // Contains access token and API response format specifier 18 | const headers = helper.getHeaderBearer(); 19 | 20 | // Data sent to Sonos API (no data needed for subscriptions) 21 | const data = {}; 22 | 23 | // Calls Sonos API to subscribe to groups events for the current household 24 | helper.apiCall(endPointGS, headers, "POST", data) 25 | .catch(function (error) { 26 | console.error(error); 27 | }); 28 | 29 | // When component is unmounted, it unsubscribes to group events for current household 30 | return () => { 31 | helper.apiCall(endPointGS, headers, "DELETE", data) 32 | .catch(function (error) { 33 | console.error(error); 34 | }); 35 | }; 36 | }, []); 37 | } 38 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/UserDetails/playerVolumeSubscribe.js: -------------------------------------------------------------------------------- 1 | import Helper from "../Utility/helper"; 2 | import { useEffect } from "react"; 3 | 4 | /** 5 | * Functional component that subscribes to a player's volume change events 6 | * Unsubscribes on unmounting of component 7 | * @param props.playerId {string} Used to target specific player in Sonos API calls 8 | */ 9 | export default function PlayerVolumeSubscribe(props) { 10 | // Used to make API calls 11 | const helper = new Helper(); 12 | 13 | useEffect(() => { 14 | // Player subscription URL 15 | const endPoint = helper.getPlayersURL() + props.playerId + "/playerVolume/subscription"; 16 | 17 | // Contains access token and API response format specifier 18 | const headers = helper.getHeaderBearer(); 19 | 20 | // Data sent to Sonos API (no data needed for subscriptions) 21 | const data = {}; 22 | 23 | // Calls Sonos API to subscribe to player volume events for specified player 24 | helper.apiCall(endPoint, headers, "POST", data) 25 | .catch(function (error) { 26 | console.error(error); 27 | }); 28 | 29 | // When component is unmounted, it unsubscribes player volume events for the specified player 30 | return () => { 31 | helper.apiCall(endPoint, headers, "DELETE", data) 32 | .catch(function (error) { 33 | console.error(error); 34 | }); 35 | } 36 | }, []); 37 | } 38 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/UserDetails/subscribe.js: -------------------------------------------------------------------------------- 1 | import Helper from "../Utility/helper"; 2 | import { useEffect } from "react"; 3 | 4 | /** 5 | * Functional component that subscribes to current group's playback, volume, and playback metadata events 6 | * Unsubscribes on unmounting of component 7 | * @param props.groupId {string} Used to target current group in Sonos API calls 8 | */ 9 | export default function Subscribe(props) { 10 | // Used to make API calls 11 | const helper = new Helper(); 12 | 13 | useEffect(() => { 14 | // Playback subscription URL 15 | const endPointPB = helper.getGroupsURL() + props.groupId + "/playback/subscription"; 16 | 17 | // Group volume subscription URL 18 | const endPointGV = helper.getGroupsURL() + props.groupId + "/groupVolume/subscription"; 19 | 20 | // Playback metadata subscription URL 21 | const endPointMD = helper.getGroupsURL() + props.groupId + "/playbackMetadata/subscription"; 22 | 23 | // Contains access token and API response format specifier 24 | const headers = helper.getHeaderBearer(); 25 | 26 | // Data sent to Sonos API (no data needed for subscriptions) 27 | const data = {}; 28 | 29 | // Calls Sonos API to subscribe to playback events for current group 30 | helper.apiCall(endPointPB, headers, "POST", data) 31 | .catch(function (error) { 32 | console.error(error); 33 | }); 34 | 35 | // Calls Sonos API to subscribe to group volume events for current group 36 | helper.apiCall(endPointGV, headers, "POST", data) 37 | .catch(function (error) { 38 | console.error(error); 39 | }); 40 | 41 | // Calls Sonos API to subscribe to playback metadata events for current group 42 | helper.apiCall(endPointMD, headers, "POST", data) 43 | .catch(function (error) { 44 | console.error(error); 45 | }); 46 | 47 | // On component unmounting, it unsubscribes to all three types of events 48 | return () => { 49 | helper.apiCall(endPointPB, headers, "DELETE", data) 50 | .catch(function (error) { 51 | console.error(error); 52 | }); 53 | 54 | helper.apiCall(endPointGV, headers, "DELETE", data) 55 | .catch(function (error) { 56 | console.error(error); 57 | }); 58 | 59 | helper.apiCall(endPointMD, headers, "DELETE", data) 60 | .catch(function (error) { 61 | console.error(error); 62 | }); 63 | }; 64 | }, []); 65 | } 66 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/Utility/helper.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import Authentication from "../Authentication/authentication"; 3 | import config from "../../config.json"; 4 | 5 | /** 6 | * Helper class containing various methods commonly used throughout sample application 7 | */ 8 | class Helper { 9 | constructor() { 10 | // Used to access currently stored Sonos API access token 11 | this.authentication = new Authentication(); 12 | } 13 | 14 | /** 15 | * Sends command to Sonos API and returns result 16 | * @param endPoint {string} Sonos API command URL 17 | * @param headers {JSON} Contains Sonos API access token and configuration 18 | * @param method {string} "POST", "GET", or "DELETE" 19 | * @param data {JSON} Data to be sent with API command 20 | * @return {AxiosPromise} Sonos API response 21 | */ 22 | apiCall(endPoint, headers, method, data) { 23 | const options = { 24 | method: method, 25 | headers: headers, 26 | data: data, 27 | url: endPoint 28 | }; 29 | return axios(options); 30 | } 31 | 32 | /** 33 | * Gets header needed for Sonos API calls 34 | * @return {{Authorization: string, "Content-Type": string}} Currently stored access token, format of response 35 | */ 36 | getHeaderBearer() { 37 | return { 38 | "Content-Type": "application/json", 39 | Authorization: "Bearer " + this.authentication.getAccessToken(), 40 | }; 41 | } 42 | 43 | /** 44 | * Retrieves OAuth URL from config.json 45 | * @return {string} OAuth URL used by login page 46 | */ 47 | getOAuthUrl() { 48 | return ( 49 | config.apiEndPoints.oauthURL + 50 | "client_id=" + 51 | this.getClientId() + 52 | "&response_type=code&state=testState&scope=playback-control-all&" + 53 | "redirect_uri=" + 54 | this.getRedirectURL() 55 | ); 56 | } 57 | 58 | /** 59 | * Retrieves client ID from config.json 60 | * @return {string} client ID 61 | */ 62 | getClientId() { 63 | return config.credentials.clientId; 64 | } 65 | 66 | /** 67 | * Retrieves client secret from config.json 68 | * @return {string} secret 69 | */ 70 | getSecret() { 71 | return config.credentials.secret; 72 | } 73 | 74 | /** 75 | * Retrieves redirect URL from config.json 76 | * @return {string} redirect URL 77 | */ 78 | getRedirectURL() { 79 | return config.credentials.redirectURL; 80 | } 81 | 82 | /** 83 | * Retrieves Sonos API household URL from config.json 84 | * @return {string} household URL 85 | */ 86 | getHouseHoldURL() { 87 | return config.apiEndPoints.householdApiURL; 88 | } 89 | 90 | /** 91 | * Retrieves Sonos API group URL from config.json 92 | * @return {string} group URL 93 | */ 94 | getGroupsURL() { 95 | return config.apiEndPoints.controlApiURL; 96 | } 97 | 98 | /** 99 | * Retrieves Sonos API player URL from config.json 100 | * @return {string} player URL 101 | */ 102 | getPlayersURL() { 103 | return config.apiEndPoints.playerApiURL; 104 | } 105 | 106 | /** 107 | * Calculates and retrieves encoded clientID:secret from config.json 108 | * @return {string} B64 encoded clientID:secret 109 | */ 110 | getB64KeySecret() { 111 | return btoa(config.credentials.clientId + ":" + config.credentials.secret); 112 | } 113 | 114 | /** 115 | * Returns header needed to request access token from Sonos API 116 | * @return {{Authorization: string, "Content-Type": string}} B64 encoded clientID:secret, format of Sonos API response 117 | */ 118 | getHeadersBasic() { 119 | return { 120 | "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", 121 | Authorization: "Basic " + this.getB64KeySecret(), 122 | }; 123 | } 124 | } 125 | 126 | export default Helper; 127 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/WebSocket/MuseEventHandler.js: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from "react"; 2 | import {useRecoilCallback, useRecoilState} from "recoil"; 3 | import { SocketContext } from "./socket"; 4 | import PlaybackMetadataHandler from "../MuseDataHandlers/PlaybackMetadataHandler"; 5 | import PlaybackStateHandler from "../MuseDataHandlers/PlaybackStateHandler"; 6 | import VolumeHandler from "../MuseDataHandlers/VolumeHandler"; 7 | import playbackMetadataAtom from "../Recoil/playbackMetadataAtom"; 8 | import playbackStateAtom from "../Recoil/playbackStateAtom"; 9 | import volumeAtom from "../Recoil/volumeAtom"; 10 | import selectedGroupAtom from "../Recoil/selectedGroupAtom"; 11 | import SelectedGroupHandler from "../MuseDataHandlers/SelectedGroupHandler"; 12 | import playerVolumeAtomFamily from "../Recoil/playerVolumeAtomFamily"; 13 | import groupsInfoAtom from "../Recoil/groupsInfoAtom"; 14 | import GroupsInfoHandler from "../MuseDataHandlers/GroupsInfoHandler"; 15 | 16 | /** 17 | * Functional component that listens for Sonos API events sent via WebSocket connection from the server 18 | * Updates the state of Recoil atoms depending on the type of event received 19 | */ 20 | export default function MuseEventHandler() { 21 | // Uses WebSocket context defined in socket.js and connects to WebSocket initiated in Server/main.mjs 22 | const socket = useContext(SocketContext); 23 | 24 | const [playbackMetadataResponse, setPlaybackMetadataResponse] = useRecoilState(playbackMetadataAtom); 25 | const [playbackStateResponse, setPlaybackStateResponse] = useRecoilState(playbackStateAtom); 26 | const [volumeResponse, setVolumeResponse] = useRecoilState(volumeAtom); 27 | const [selectedGroupResponse, setSelectedGroupResponse] = useRecoilState(selectedGroupAtom); 28 | const [groupsInfoResponse, setGroupsInfoResponse] = useRecoilState(groupsInfoAtom); 29 | 30 | // useRecoilCallback was used to fetch the state of Recoil atoms without having MuseEventHandler subscribed to their states 31 | const selectedGroupSnapshot = useRecoilCallback(({snapshot}) => () => { 32 | let loadable = snapshot.getLoadable(selectedGroupAtom); 33 | return loadable.valueMaybe(); 34 | }, []); 35 | const setPlayerVolumeResponse = useRecoilCallback(({set}) => (playerId, val) => { 36 | set(playerVolumeAtomFamily(playerId), val); 37 | }, []); 38 | const groupsInfoSnapshot = useRecoilCallback(({snapshot}) => () => { 39 | let loadable = snapshot.getLoadable(groupsInfoAtom); 40 | return loadable.valueMaybe(); 41 | }, []); 42 | 43 | // Sets up a callback function to handle incoming messages 44 | useEffect(() => { 45 | if (socket !== undefined) { 46 | // Receive the events from server via WebSocket connection 47 | socket.on("message from server", (requestData) => { 48 | // An event has been received from the server and will be processed based on the type of event 49 | if (requestData.headers !== undefined) { 50 | // Filters events to ensure only group events targeting the current group are acted on 51 | if (requestData.headers["x-sonos-target-value"] === selectedGroupSnapshot().groupId) { 52 | if (getMethodType(requestData) === "playbackStatus") { 53 | const res = PlaybackStateHandler(requestData.data); 54 | setPlaybackStateResponse(res); 55 | } else if (getMethodType(requestData) === "groupVolume") { 56 | const res = VolumeHandler(requestData.data); 57 | setVolumeResponse(res); 58 | } else if (getMethodType(requestData) === "metadataStatus") { 59 | const res = PlaybackMetadataHandler(requestData.data); 60 | setPlaybackMetadataResponse(res); 61 | } else if (getMethodType(requestData) === "groupCoordinatorChanged") { 62 | const res = SelectedGroupHandler(requestData.data); 63 | res.groupId = selectedGroupSnapshot().groupId; 64 | setSelectedGroupResponse(res); 65 | } 66 | } else if (getMethodType(requestData) === "playerVolume") { 67 | // Uses message's target to determine which player's volume to update 68 | const res = VolumeHandler(requestData.data); 69 | setPlayerVolumeResponse(requestData.headers["x-sonos-target-value"], res); 70 | } else if (getMethodType(requestData) === "groups" && requestData.headers["x-sonos-target-value"] === groupsInfoSnapshot().householdId) { 71 | // Filters events to ensure that only events targeting the current household are acted on 72 | const res = GroupsInfoHandler(requestData.data); 73 | res.householdId = groupsInfoSnapshot().householdId; 74 | setGroupsInfoResponse(res); 75 | } 76 | } 77 | }); 78 | } 79 | }, []); 80 | } 81 | 82 | /** 83 | * Retrieves message type from Sonos API message 84 | * @param request {JSON} Message received by client via WebSocket connection 85 | * @return {string} Sonos API message type 86 | */ 87 | function getMethodType(request) { 88 | try { 89 | return request.headers["x-sonos-type"]; 90 | } catch (e) { 91 | console.error("Error in fetching method type...", e); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/WebSocket/socket.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import config from "../../config.json" 3 | import { io } from "socket.io-client"; 4 | 5 | /** 6 | * Defines WebSocket reference for use in MuseEventHandler 7 | * Connects to WebSocket initiated in Server 8 | */ 9 | export const socket = io.connect(config.socketURL); 10 | export const SocketContext = React.createContext(); 11 | -------------------------------------------------------------------------------- /sample-app/Client/src/App/museClient/README.md: -------------------------------------------------------------------------------- 1 | # Requirements 2 | 3 | You must have at least v12.x of Node.js to run the OAS generator. 4 | 5 | # Open API Specification Generator 6 | 1. Open a new terminal/command prompt window and run the command 'brew install swagger-codegen' 7 | 2. navigate to the path where you want to generate the code. In this project we have saved the files in the path 'museClient'. e.g. cd /museClient 8 | 3. paste the command 'swagger-codegen generate -i SonosControlApi.json -l javascript -o /tmp/api --additional-properties useEs6=true'. 9 | 4. paste the command 'cp /tmp/api/{api.js,configuration.js} .'. This will copy the files generated from the previous command. -------------------------------------------------------------------------------- /sample-app/Client/src/App/museClient/configuration.js: -------------------------------------------------------------------------------- 1 | // tslint:disable 2 | /** 3 | * Sonos Control API 4 | * The Sonos Control API is the primary means by which your application will interact with Sonos groups. First, discover available groups and players. Then use Control API commands to control players. For example, perform basic transport control such as play, pause, or skip. Or subscribe to receive events from the player, like track metadata. 5 | * 6 | * OpenAPI spec version: v2.0.0-production 7 | * 8 | * 9 | * NOTE: This file is auto generated by the swagger code generator program. 10 | * https://github.com/swagger-api/swagger-codegen.git 11 | * Do not edit the file manually. 12 | */ 13 | export class Configuration { 14 | constructor(param = {}) { 15 | this.apiKey = param.apiKey; 16 | this.username = param.username; 17 | this.password = param.password; 18 | this.accessToken = param.accessToken; 19 | this.basePath = param.basePath; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /sample-app/Client/src/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "credentials": { 3 | "clientId": "", 4 | "secret": "", 5 | "redirectURL": "/oauth (ex: https://test.trycloudflare.com/oauth)" 6 | }, 7 | "apiEndPoints": { 8 | "oauthURL":"https://api.sonos.com/login/v3/oauth?", 9 | "createRefreshAuthTokenURL" : "http://127.0.0.1:8090/https://api.sonos.com/login/v3/oauth/access", 10 | "householdApiURL":"http://127.0.0.1:8090/https://api.ws.sonos.com/control/api/v1/households/", 11 | "controlApiURL":"http://127.0.0.1:8090/https://api.ws.sonos.com/control/api/v1/groups/", 12 | "playerApiURL":"http://127.0.0.1:8090/https://api.ws.sonos.com/control/api/v1/players/" 13 | }, 14 | "socketURL":"ws://localhost:8000", 15 | "serviceLogosURL":"http://127.0.0.1:8090/https://service-catalog.ws.sonos.com/mslogo" 16 | } 17 | -------------------------------------------------------------------------------- /sample-app/Client/src/css/controlPage.css: -------------------------------------------------------------------------------- 1 | /** 2 | Defines appearance of group playback page 3 | */ 4 | 5 | .group_name { 6 | height: 10%; 7 | margin-top: 5%; 8 | box-sizing: border-box; 9 | display: block; 10 | color: #000000; 11 | position: relative; 12 | } 13 | 14 | .group_box { 15 | position:relative; 16 | width: 40%; 17 | height: 38px; 18 | margin: 2% auto; 19 | box-sizing: border-box; 20 | display: block; 21 | color: #000000; 22 | background-color: black; 23 | z-index: 100; 24 | } 25 | 26 | .group_box p { 27 | top: 16%; 28 | text-align: center; 29 | position: relative; 30 | font-style: normal; 31 | font-weight: 600; 32 | color: white; 33 | font-size: large; 34 | } 35 | 36 | .player { 37 | position:relative; 38 | display: flex; 39 | align-items: center; 40 | flex-direction: column; 41 | margin-top: -80px; 42 | } 43 | 44 | .track_details { 45 | display: flex; 46 | align-items: center; 47 | flex-direction: column; 48 | justify-content: center; 49 | margin-top: 50px; 50 | text-align: center; 51 | margin-bottom: 40px; 52 | } 53 | 54 | .track_name { 55 | font-size: 2rem; 56 | } 57 | 58 | .album_name { 59 | font-size: 1rem; 60 | } 61 | 62 | .track_image { 63 | margin: 25px; 64 | height: 300px; 65 | width: 300px; 66 | background-size: cover; 67 | } 68 | 69 | .msp_logo { 70 | background-color: black; 71 | opacity: 0.5; 72 | border-radius: 30%; 73 | height:40px; 74 | width:60px; 75 | display: block; 76 | align-content: center; 77 | } 78 | 79 | .group_buttons { 80 | display: flex; 81 | flex-direction: row; 82 | align-items: center; 83 | position: relative; 84 | } 85 | 86 | .playpause_track, 87 | .group_prev, 88 | .group_next { 89 | padding: 15px; 90 | opacity: 0.8; 91 | transition: opacity 0.2s; 92 | } 93 | 94 | .group_prev_disabled, 95 | .group_next_disabled { 96 | padding: 15px; 97 | opacity: 0.2; 98 | transition: opacity 0.2s; 99 | } 100 | 101 | .playpause_track:hover, 102 | .group_prev:hover, 103 | .group_next:hover, 104 | .shuffled:hover, 105 | .not_shuffled:hover, 106 | .repeat:hover, 107 | .repeat_one:hover, 108 | .not_repeat:hover { 109 | opacity: 1; 110 | } 111 | 112 | .repeat { 113 | width: 50px; 114 | margin-right: 50px; 115 | opacity: 0.75; 116 | transition: opacity 0.2s; 117 | } 118 | 119 | .not_repeat { 120 | width: 50px; 121 | margin-right: 50px; 122 | opacity: 0.2; 123 | transition: opacity 0.2s; 124 | } 125 | 126 | .shuffled { 127 | width: 50px; 128 | margin-left: 50px; 129 | opacity: 0.8; 130 | transition: opacity 0.2s; 131 | } 132 | 133 | .not_shuffled { 134 | width: 50px; 135 | margin-left: 50px; 136 | opacity: 0.2; 137 | transition: opacity 0.2s; 138 | } 139 | 140 | i.fa-play-circle, 141 | i.fa-pause-circle, 142 | i.fa-step-forward, 143 | i.fa-step-backward, 144 | i.fa-random, 145 | img.pointer { 146 | cursor: pointer; 147 | } 148 | 149 | i.fa-volume-down, 150 | i.fa-volume-up { 151 | padding: 10px; 152 | } 153 | 154 | .slider_container { 155 | width: 75%; 156 | max-width: 400px; 157 | display: flex; 158 | justify-content: center; 159 | align-items: center; 160 | position: relative; 161 | margin-top: -20px; 162 | } 163 | 164 | .seek_slider, 165 | .groupVolumeSlider { 166 | -webkit-appearance: none; 167 | -moz-appearance: none; 168 | appearance: none; 169 | height: 5px; 170 | background: black; 171 | opacity: 0.7; 172 | -webkit-transition: 0.2s; 173 | transition: opacity 0.2s; 174 | } 175 | 176 | .seek_slider::-webkit-slider-thumb, 177 | .groupVolumeSlider::-webkit-slider-thumb { 178 | -webkit-appearance: none; 179 | -moz-appearance: none; 180 | appearance: none; 181 | width: 15px; 182 | height: 15px; 183 | background: black; 184 | border-radius: 50%; 185 | position: relative; 186 | cursor: pointer; 187 | } 188 | 189 | .seek_slider:hover, 190 | .groupVolumeSlider:hover { 191 | cursor: pointer; 192 | opacity: 1; 193 | } 194 | 195 | .groupVolumeSlider { 196 | width: 50%; 197 | } 198 | 199 | .seek_slider { 200 | width: 60%; 201 | } 202 | 203 | .play_back_metadata { 204 | align-items: center; 205 | } 206 | 207 | .group_volume { 208 | font-size: small; 209 | margin-right: 150px; 210 | } 211 | 212 | .dropdown_menu { 213 | position:relative; 214 | width: 40%; 215 | margin: 0 auto; 216 | } 217 | 218 | .playback_item { 219 | margin-top: 10px; 220 | display: block; 221 | position: relative; 222 | width: 50%; 223 | height: 10%; 224 | left: 25%; 225 | background: #d9d9d9; 226 | mix-blend-mode: normal; 227 | display: flex; 228 | justify-content: center; 229 | align-items: center; 230 | font-size: 20px; 231 | opacity : 0.8; 232 | cursor:pointer; 233 | } 234 | 235 | .react_select_container .react_select__control { 236 | background-color: black; 237 | color: white; 238 | } 239 | 240 | .react_select_container .react_select__single-value { 241 | color: white; 242 | } 243 | 244 | .react_select_container .react_select__menu { 245 | background-color: black; 246 | color: white; 247 | } 248 | 249 | .react_select_container .react_select__option { 250 | color: white; 251 | } 252 | 253 | .react_select_container .react_select__option--is-focused { 254 | background-color: #6F6F6F; 255 | color: white; 256 | } 257 | 258 | .react_select_container .react_select__option--is-selected { 259 | background-color: #3F3F3F; 260 | color: white; 261 | } 262 | -------------------------------------------------------------------------------- /sample-app/Client/src/css/dashboard.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Defines appearance of households and groups selection pages 3 | */ 4 | 5 | body { 6 | background-color: linen; 7 | transition: background-color 0.5s; 8 | } 9 | 10 | .main_page { 11 | box-sizing: border-box; 12 | position: absolute; 13 | width: 40%; 14 | height: 90%; 15 | top: 5%; 16 | left: 30%; 17 | border: 1px solid #000000; 18 | filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.25)); 19 | float: left; 20 | bottom: 5%; 21 | } 22 | .main_page > * { 23 | display: block; 24 | width: 100%; 25 | } 26 | 27 | .group_text { 28 | text-align: center; 29 | 30 | display: block; 31 | font-style: normal; 32 | font-weight: 500; 33 | font-size: 32px; 34 | color: #000000; 35 | margin-top: 5%; 36 | } 37 | 38 | .back_button { 39 | position: absolute; 40 | margin-top: 4%; 41 | margin-left: 2%; 42 | z-index: 100; 43 | } 44 | 45 | i.fa-chevron-circle-left { 46 | cursor: pointer; 47 | } 48 | 49 | .group_text p { 50 | top: 42%; 51 | text-align: center; 52 | position: relative; 53 | } 54 | 55 | .group_det { 56 | height: 10%; 57 | display: block; 58 | } 59 | 60 | .group_ind { 61 | position: absolute; 62 | width: 50%; 63 | height: 8%; 64 | left: 25%; 65 | background: #d9d9d9; 66 | mix-blend-mode: normal; 67 | display: flex; 68 | justify-content: center; 69 | align-items: center; 70 | font-size: 20px; 71 | opacity : 0.8; 72 | cursor:pointer; 73 | } 74 | 75 | .household_det { 76 | width: 50%; 77 | left: 25%; 78 | min-height: 9%; 79 | background: #d9d9d9; 80 | position: relative; 81 | mix-blend-mode: normal; 82 | display: flex; 83 | justify-content: center; 84 | cursor:pointer; 85 | align-content: center; 86 | margin-bottom: 3%; 87 | } 88 | 89 | .household_ind { 90 | font-size: 20px; 91 | opacity : 0.8; 92 | align-content: center; 93 | align-self: center; 94 | position: relative; 95 | margin-bottom: 0; 96 | text-align: center; 97 | } 98 | 99 | .household_player_name { 100 | position: relative; 101 | width: 50%; 102 | height: 1px; 103 | left: 25%; 104 | display: block; 105 | font-size: 12px; 106 | opacity : 0.8; 107 | cursor:pointer; 108 | align-items: flex-end; 109 | text-align: center; 110 | align-content: center; 111 | } 112 | 113 | .group_ind:hover { 114 | opacity: 1; 115 | } 116 | 117 | .selected_group_page { 118 | box-sizing: border-box; 119 | position: absolute; 120 | width: 40%; 121 | top: 5%; 122 | left: 30%; 123 | border: 1px solid #000000; 124 | filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.25)); 125 | float: left; 126 | border-bottom-style: none; 127 | align-items: center; 128 | } 129 | .selected_group_page > * { 130 | display: block; 131 | width: 100%; 132 | } 133 | -------------------------------------------------------------------------------- /sample-app/Client/src/css/login.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Defines appearance of login page 3 | */ 4 | .oauth_bkg { 5 | box-sizing: border-box; 6 | display: block; 7 | } 8 | 9 | .login_with_sonos_text { 10 | box-sizing: border-box; 11 | height: 16%; 12 | text-align: center; 13 | 14 | display: block; 15 | 16 | font-style: normal; 17 | font-weight: 400; 18 | font-size: 20px; 19 | 20 | color: #000000; 21 | } 22 | 23 | .login_with_sonos_text p { 24 | top: 42%; 25 | text-align: center; 26 | position: relative; 27 | } 28 | 29 | .login_btn { 30 | box-sizing: border-box; 31 | display: block; 32 | position: absolute; 33 | width: 30%; 34 | left: 35%; 35 | height: 8%; 36 | 37 | background: #0a0909; 38 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25); 39 | border-radius: 10px; 40 | 41 | text-align: center; 42 | color: white; 43 | font-style: normal; 44 | font-weight: 700; 45 | font-size: 14px; 46 | line-height: 17px; 47 | } 48 | -------------------------------------------------------------------------------- /sample-app/Client/src/css/navbar.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Defines appearance of navigation bar at the top of the screen 3 | */ 4 | 5 | .logo_logout { 6 | height: 5%; 7 | width: 100%; 8 | display: table; 9 | border: 1px solid #000000; 10 | } 11 | 12 | .logo { 13 | float: left; 14 | height: 100%; 15 | width: 70%; 16 | } 17 | 18 | .logout { 19 | cursor:pointer; 20 | float: right; 21 | height: 80%; 22 | width: 25%; 23 | margin-top: 2.5%; 24 | margin-right: 2.5%; 25 | } 26 | 27 | .render_page_small { 28 | align-items: center; 29 | margin-top:10%; 30 | margin-left: 40%; 31 | } 32 | 33 | .render_page { 34 | align-items: center; 35 | margin-top: 400px; 36 | margin-left: 350px; 37 | } 38 | -------------------------------------------------------------------------------- /sample-app/Client/src/css/players.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Defines appearance of each player's component 3 | */ 4 | 5 | .player_component { 6 | margin-left:25%; 7 | } 8 | 9 | .player_slider_container { 10 | width: 75%; 11 | max-width: 400px; 12 | display: flex; 13 | justify-content: center; 14 | align-items: center; 15 | position: relative; 16 | } 17 | 18 | .seek_slider, 19 | .volumeSlider { 20 | -webkit-appearance: none; 21 | -moz-appearance: none; 22 | appearance: none; 23 | height: 5px; 24 | background: black; 25 | opacity: 0.7; 26 | -webkit-transition: 0.2s; 27 | transition: opacity 0.2s; 28 | } 29 | 30 | .seek_slider::-webkit-slider-thumb, 31 | .volumeSlider::-webkit-slider-thumb { 32 | -webkit-appearance: none; 33 | -moz-appearance: none; 34 | appearance: none; 35 | width: 15px; 36 | height: 15px; 37 | background: black; 38 | border-radius: 50%; 39 | position: relative; 40 | cursor: pointer; 41 | } 42 | 43 | .seek_slider:hover, 44 | .volumeSlider:hover { 45 | cursor: pointer; 46 | opacity: 1; 47 | } 48 | 49 | .volumeSlider { 50 | width: 30%; 51 | } 52 | 53 | input[type="checkbox"] { 54 | -webkit-appearance: none; 55 | -moz-appearance: none; 56 | appearance: none; 57 | 58 | display: inline-block; 59 | width: 25px; 60 | height: 25px; 61 | padding: 6px; 62 | 63 | background-clip: content-box; 64 | border: 1.5px solid #bbbbbb; 65 | border-radius: 6px; 66 | background-color: #e7e6e7; 67 | margin-left: 15px; 68 | margin-right: 15px; 69 | cursor: pointer; 70 | 71 | &:checked { 72 | background-color: black; 73 | } 74 | 75 | &:focus{ 76 | outline: none !important; 77 | } 78 | } 79 | 80 | .checkbox label { 81 | cursor: pointer; 82 | display: flex; 83 | align-items: center; 84 | margin-left: 12%; 85 | } 86 | -------------------------------------------------------------------------------- /sample-app/Client/src/images/Sample App - SONOS_files/logo.62c8a02825cb8d902bab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonos/api-web-sample-app/11b83c459209a5a193bab28e39a6f0f97942ef16/sample-app/Client/src/images/Sample App - SONOS_files/logo.62c8a02825cb8d902bab.png -------------------------------------------------------------------------------- /sample-app/Client/src/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonos/api-web-sample-app/11b83c459209a5a193bab28e39a6f0f97942ef16/sample-app/Client/src/images/logo.png -------------------------------------------------------------------------------- /sample-app/Client/src/images/logout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonos/api-web-sample-app/11b83c459209a5a193bab28e39a6f0f97942ef16/sample-app/Client/src/images/logout.png -------------------------------------------------------------------------------- /sample-app/Client/src/images/repeat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonos/api-web-sample-app/11b83c459209a5a193bab28e39a6f0f97942ef16/sample-app/Client/src/images/repeat.png -------------------------------------------------------------------------------- /sample-app/Client/src/images/repeat_one.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonos/api-web-sample-app/11b83c459209a5a193bab28e39a6f0f97942ef16/sample-app/Client/src/images/repeat_one.png -------------------------------------------------------------------------------- /sample-app/Client/src/images/shuffle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonos/api-web-sample-app/11b83c459209a5a193bab28e39a6f0f97942ef16/sample-app/Client/src/images/shuffle.png -------------------------------------------------------------------------------- /sample-app/Client/src/images/sonos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonos/api-web-sample-app/11b83c459209a5a193bab28e39a6f0f97942ef16/sample-app/Client/src/images/sonos.png -------------------------------------------------------------------------------- /sample-app/Client/src/images/sonos_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonos/api-web-sample-app/11b83c459209a5a193bab28e39a6f0f97942ef16/sample-app/Client/src/images/sonos_background.png -------------------------------------------------------------------------------- /sample-app/Client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import {RecoilRoot} from 'recoil'; 4 | import "bootstrap/dist/css/bootstrap.css"; 5 | 6 | import "./css/login.css"; 7 | import "./css/dashboard.css"; 8 | import "./css/navbar.css" 9 | import "./css/controlPage.css"; 10 | import "./css/players.css"; 11 | 12 | import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; 13 | 14 | import RouteComponents from "./App/Controllers/routingController"; 15 | import RouteGroup from "./App/Routing/routeGroup"; 16 | import RouteHousehold from "./App/Routing/routeHousehold"; 17 | import {socket, SocketContext} from "./App/WebSocket/socket"; 18 | import MuseEventHandler from "./App/WebSocket/MuseEventHandler"; 19 | 20 | const root = ReactDOM.createRoot(document.getElementById("root")); 21 | root.render( 22 |
23 | 24 | 25 | 26 | } /> 27 | } /> 28 | }/> 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 | ); 37 | -------------------------------------------------------------------------------- /sample-app/Client/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /sample-app/Client/webpack.config.js: -------------------------------------------------------------------------------- 1 | //resolve.fallback = { "querystring": require.resolve("querystring-es3") } 2 | 3 | //resolve.fallback = { "url": require.resolve("url/") } 4 | 5 | resolve.fallback = { "url": false } -------------------------------------------------------------------------------- /sample-app/Cors/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install --registry=https://registry.npmjs.org/ 8 | 9 | COPY . . 10 | 11 | CMD [ "npm", "start" ] 12 | -------------------------------------------------------------------------------- /sample-app/Cors/cors-format.js: -------------------------------------------------------------------------------- 1 | // Listen on a specific host via the HOST environment variable 2 | var host = process.env.HOST || '0.0.0.0'; 3 | // Listen on a specific port via the PORT environment variable 4 | var port = 8090; 5 | 6 | var cors_proxy = require('cors-anywhere'); 7 | cors_proxy.createServer({ 8 | originWhitelist: [], // Allow all origins 9 | }).listen(port, host, function() { 10 | console.log('Running CORS Anywhere on ' + host + ':' + port); 11 | }); 12 | -------------------------------------------------------------------------------- /sample-app/Cors/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Cors", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "cors-anywhere": "0.4.4", 9 | "http-proxy": "1.11.1", 10 | "proxy-from-env": "0.0.1" 11 | } 12 | }, 13 | "node_modules/cors-anywhere": { 14 | "version": "0.4.4", 15 | "resolved": "https://registry.npmjs.org/cors-anywhere/-/cors-anywhere-0.4.4.tgz", 16 | "integrity": "sha512-8OBFwnzMgR4mNrAeAyOLB2EruS2z7u02of2bOu7i9kKYlZG+niS7CTHLPgEXKWW2NAOJWRry9RRCaL9lJRjNqg==", 17 | "dependencies": { 18 | "http-proxy": "1.11.1", 19 | "proxy-from-env": "0.0.1" 20 | }, 21 | "engines": { 22 | "node": ">=0.10.0" 23 | } 24 | }, 25 | "node_modules/eventemitter3": { 26 | "version": "1.2.0", 27 | "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-1.2.0.tgz", 28 | "integrity": "sha512-DOFqA1MF46fmZl2xtzXR3MPCRsXqgoFqdXcrCVYM3JNnfUeHTm/fh/v/iU7gBFpwkuBmoJPAm5GuhdDfSEJMJA==" 29 | }, 30 | "node_modules/http-proxy": { 31 | "version": "1.11.1", 32 | "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.11.1.tgz", 33 | "integrity": "sha512-qz7jZarkVG3G6GMq+4VRJPSN4NkIjL4VMTNhKGd8jc25BumeJjWWvnY3A7OkCGa8W1TTxbaK3dcE0ijFalITVA==", 34 | "dependencies": { 35 | "eventemitter3": "1.x.x", 36 | "requires-port": "0.x.x" 37 | }, 38 | "engines": { 39 | "node": ">=0.10.0" 40 | } 41 | }, 42 | "node_modules/proxy-from-env": { 43 | "version": "0.0.1", 44 | "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-0.0.1.tgz", 45 | "integrity": "sha512-B9Hnta3CATuMS0q6kt5hEezOPM+V3dgaRewkFtFoaRQYTVNsHqUvFXmndH06z3QO1ZdDnRELv5vfY6zAj/gG7A==" 46 | }, 47 | "node_modules/requires-port": { 48 | "version": "0.0.1", 49 | "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-0.0.1.tgz", 50 | "integrity": "sha512-AzPDCliPoWDSvEVYRQmpzuPhGGEnPrQz9YiOEvn+UdB9ixBpw+4IOZWtwctmpzySLZTy7ynpn47V14H4yaowtA==" 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /sample-app/Cors/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "cors-anywhere": "0.4.4", 4 | "http-proxy": "1.11.1", 5 | "proxy-from-env": "0.0.1" 6 | }, 7 | "scripts": { 8 | "start": "npm run server", 9 | "server": "node cors-format.js" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /sample-app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | 3 | # Working dir 4 | WORKDIR /app/Client 5 | 6 | # Copy files from Build 7 | COPY /Client/package*.json ./ 8 | 9 | # Install Files 10 | RUN npm install --registry=https://registry.npmjs.org/ 11 | 12 | # Copy SRC 13 | COPY ./Client . 14 | 15 | 16 | # Working dir 17 | WORKDIR /app/Server 18 | 19 | # Copy files from Build 20 | COPY /Server/package*.json ./ 21 | 22 | # Copy SRC 23 | COPY ./Server . 24 | 25 | # Install Files 26 | RUN npm install --registry=https://registry.npmjs.org/ 27 | 28 | # Open Port 29 | EXPOSE 3000 30 | 31 | # Docker Command to Start Service 32 | CMD [ "npm", "start" ] 33 | -------------------------------------------------------------------------------- /sample-app/Server/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /sample-app/Server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | 3 | # Working dir 4 | WORKDIR /app/server 5 | 6 | # Copy files from Build 7 | COPY package*.json ./ 8 | 9 | # Install Files 10 | RUN npm install --registry=https://registry.npmjs.org/ 11 | 12 | # Copy SRC 13 | COPY . . 14 | 15 | # Open Port 16 | EXPOSE 8080 8000 17 | 18 | # Docker Command to Start Service 19 | CMD [ "npm", "start" ] -------------------------------------------------------------------------------- /sample-app/Server/main.mjs: -------------------------------------------------------------------------------- 1 | import { Server } from "socket.io"; 2 | import { createServer } from "http"; 3 | import express from "express"; 4 | 5 | /* 6 | * This file defines and initiates the server that listens for Sonos API events, along with the WebSocket connection between the server 7 | * and client that allows the server to send events to the client. 8 | * Sonos API sends events to the ngrok URL, which the server receives at port 8080. The server then sends this request to the WebSocket at port 8000 9 | * The client can then receive that event in MuseEventHandler by listening to "ws://localhost:8000" (see socket.js) 10 | */ 11 | 12 | // Defines WebSocket connection between client and server 13 | const httpServer = createServer(); 14 | const io = new Server(httpServer, { 15 | cors: { 16 | origin: "*", 17 | }, 18 | }); 19 | 20 | // Initiates WebSocket connection 21 | httpServer.listen(8000); 22 | 23 | // Logs messages and connections from the client 24 | io.on("connection", (socket) => { 25 | console.log("Connected to client...", socket.id); 26 | socket.on("hello from client", (data) => { 27 | console.log(data); 28 | }); 29 | }); 30 | 31 | /** 32 | * Sends data from server to client through WebSocket connection 33 | * @param data Data received from Sonos API event 34 | */ 35 | function sendRequest(data) { 36 | io.emit("message from server", data); 37 | } 38 | 39 | 40 | // Defines and initiates server that listens to incoming Sonos API events 41 | const app = express(); 42 | const PORT = 8080; 43 | app.listen(PORT, (error) => { 44 | if (!error) 45 | console.log( 46 | "Server is Successfully Running, and App is listening on port " + PORT 47 | ); 48 | else console.log("Error occurred, server can't start", error); 49 | }); 50 | app.use( 51 | express.urlencoded({ 52 | extended: true, 53 | }) 54 | ); 55 | app.use(express.json()); 56 | app.use(function(req, res, next) { 57 | res.header("Access-Control-Allow-Origin", "*"); 58 | res.header("Access-Control-Allow-Headers", "*"); 59 | next(); 60 | }); 61 | 62 | 63 | // If an event is received, event is logged in server console and sent to client through the WebSocket connection 64 | app.post("/", (req, res) => { 65 | console.log("Post request received..."); 66 | const headers = req.headers; 67 | const data = req.body; 68 | console.log(data); 69 | console.log("\n ...End of request...\n"); 70 | sendRequest({ headers: headers, data: data }); 71 | }); 72 | 73 | app.get("/oauth", (req, res) => { 74 | const { state, code } = req.query; 75 | const redirectTo = `http://localhost:3000?state=${state}&code=${code}`; 76 | 77 | res.redirect(302, redirectTo); 78 | 79 | console.log(`Redirecting /oauth to ${redirectTo}`); 80 | }); 81 | 82 | // If localhost:8000 is navigated to, Hello World is displayed 83 | app.get("/", (req, res) => { 84 | console.log("GET request received..."); 85 | res.send("Hello World"); 86 | }); 87 | -------------------------------------------------------------------------------- /sample-app/Server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "axios": "^0.27.2", 4 | "body-parser": "^1.20.0", 5 | "express": "^4.18.1", 6 | "http": "^0.0.1-security", 7 | "socket.io": "^4.5.1", 8 | "websocket": "^1.0.34" 9 | }, 10 | "scripts": { 11 | "start": "npm run server", 12 | "server": "node main.mjs" 13 | }, 14 | "devDependencies": { 15 | "concurrently": "^7.3.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /sample-app/Test-cors/group.txt: -------------------------------------------------------------------------------- 1 | { 2 | "_objectType": "groups", 3 | "groups": [ 4 | { 5 | "_objectType": "group", 6 | "id": "RINCON_2439B3B3BDC4290:6783439021", 7 | "name": "Sonos Roam", 8 | "coordinatorId": "RINCON_2439B3B3BDC4290", 9 | "playbackState": "PLAYBACK_STATE_IDLE", 10 | "playerIds": [ 11 | "RINCON_2439B3B3BDC4290" 12 | ] 13 | }, 14 | { 15 | "_objectType": "group", 16 | "id": "RINCON_2439B3B3BDC4290:6783439021", 17 | "name": "Bedroom", 18 | "coordinatorId": "RINCON_2439B3B3BDC4290", 19 | "playbackState": "PLAYBACK_STATE_PLAYING", 20 | "playerIds": [ 21 | "RINCON_2439B3B3BDC4290" 22 | ] 23 | } 24 | ], 25 | "players": [ 26 | { 27 | "_objectType": "player", 28 | "id": "RINCON_2439B3B3BDC4290", 29 | "name": "Sonos Roam", 30 | "websocketUrl": "wss://192.168.1.45:1443/websocket/api", 31 | "softwareVersion": "75.1-43188-main_release", 32 | "apiVersion": "1.36.0-alpha.10", 33 | "minApiVersion": "1.1.0", 34 | "isUnregistered": false, 35 | "capabilities": [ 36 | "CLOUD", 37 | "PLAYBACK", 38 | "AIRPLAY", 39 | "VOICE", 40 | "AUDIO_CLIP" 41 | ], 42 | "deviceIds": [ 43 | "RINCON_2439B3B3BDC4290" 44 | ] 45 | }, 46 | { 47 | "_objectType": "player", 48 | "id": "RINCON_2439B3B3BDC4290", 49 | "name": "Bedroom", 50 | "websocketUrl": "wss://192.168.1.81:1443/websocket/api", 51 | "softwareVersion": "76.1-43310-main_release", 52 | "apiVersion": "1.37.0-alpha.7", 53 | "minApiVersion": "1.1.0", 54 | "isUnregistered": false, 55 | "capabilities": [ 56 | "CLOUD", 57 | "HT_PLAYBACK", 58 | "PLAYBACK", 59 | "HT_POWER_STATE", 60 | "AIRPLAY", 61 | "VOICE", 62 | "AUDIO_CLIP", 63 | "HDMI" 64 | ], 65 | "deviceIds": [ 66 | "RINCON_2439B3B3BDC4290" 67 | ] 68 | } 69 | ], 70 | "partial": false 71 | } -------------------------------------------------------------------------------- /sample-app/Test-cors/household.txt: -------------------------------------------------------------------------------- 1 | { 2 | "households": [ 3 | { 4 | "id": "Sonos_iHJBfvUzznmHiuYmP2aT18usVp.NoCK8hTDBL4BUwR8k2Fe", 5 | "name": null, 6 | "ownerLuid": "364896036" 7 | } 8 | ] 9 | } -------------------------------------------------------------------------------- /sample-app/Test-cors/mock-cors.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const { url } = require('inspector'); 3 | const fs = require('fs') 4 | 5 | const hostname = '127.0.0.1' 6 | const port = 8090 7 | 8 | const server = http.createServer((req, res) => { 9 | res.statusCode = 200; 10 | res.setHeader('Content-Type', 'text/plain'); 11 | if(req.url.includes('groups')) 12 | { 13 | //this is a mock group response statement 14 | const fileContent = fs.readFileSync('./group.txt', 'utf-8'); 15 | res.end(fileContent); 16 | } else { 17 | //this is a mock household response statement 18 | const fileContent = fs.readFileSync('./household.txt', 'utf-8'); 19 | res.end(fileContent); 20 | } 21 | 22 | }); 23 | 24 | server.listen(port, hostname, () => { 25 | console.log(`Server running at http://${hostname}:${port}/`); 26 | }); -------------------------------------------------------------------------------- /sample-app/Test-cors/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Cors", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "cors-anywhere": "0.4.4", 9 | "http-proxy": "1.11.1", 10 | "proxy-from-env": "0.0.1" 11 | } 12 | }, 13 | "node_modules/cors-anywhere": { 14 | "version": "0.4.4", 15 | "resolved": "https://registry.npmjs.org/cors-anywhere/-/cors-anywhere-0.4.4.tgz", 16 | "integrity": "sha512-8OBFwnzMgR4mNrAeAyOLB2EruS2z7u02of2bOu7i9kKYlZG+niS7CTHLPgEXKWW2NAOJWRry9RRCaL9lJRjNqg==", 17 | "dependencies": { 18 | "http-proxy": "1.11.1", 19 | "proxy-from-env": "0.0.1" 20 | }, 21 | "engines": { 22 | "node": ">=0.10.0" 23 | } 24 | }, 25 | "node_modules/eventemitter3": { 26 | "version": "1.2.0", 27 | "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-1.2.0.tgz", 28 | "integrity": "sha512-DOFqA1MF46fmZl2xtzXR3MPCRsXqgoFqdXcrCVYM3JNnfUeHTm/fh/v/iU7gBFpwkuBmoJPAm5GuhdDfSEJMJA==" 29 | }, 30 | "node_modules/http-proxy": { 31 | "version": "1.11.1", 32 | "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.11.1.tgz", 33 | "integrity": "sha512-qz7jZarkVG3G6GMq+4VRJPSN4NkIjL4VMTNhKGd8jc25BumeJjWWvnY3A7OkCGa8W1TTxbaK3dcE0ijFalITVA==", 34 | "dependencies": { 35 | "eventemitter3": "1.x.x", 36 | "requires-port": "0.x.x" 37 | }, 38 | "engines": { 39 | "node": ">=0.10.0" 40 | } 41 | }, 42 | "node_modules/proxy-from-env": { 43 | "version": "0.0.1", 44 | "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-0.0.1.tgz", 45 | "integrity": "sha512-B9Hnta3CATuMS0q6kt5hEezOPM+V3dgaRewkFtFoaRQYTVNsHqUvFXmndH06z3QO1ZdDnRELv5vfY6zAj/gG7A==" 46 | }, 47 | "node_modules/requires-port": { 48 | "version": "0.0.1", 49 | "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-0.0.1.tgz", 50 | "integrity": "sha512-AzPDCliPoWDSvEVYRQmpzuPhGGEnPrQz9YiOEvn+UdB9ixBpw+4IOZWtwctmpzySLZTy7ynpn47V14H4yaowtA==" 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /sample-app/Test-cors/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "cors-anywhere": "0.4.4", 4 | "http-proxy": "1.11.1", 5 | "proxy-from-env": "0.0.1" 6 | }, 7 | "scripts": { 8 | "start": "npm run server", 9 | "server": "node cors-format.js" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /sample-app/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | server: 5 | build: 6 | context: ./server 7 | dockerfile: ./Dockerfile 8 | image: "sample-app-server" 9 | ports: 10 | - "8080:8080" 11 | - "8000:8000" 12 | client: 13 | build: 14 | context: ./client 15 | dockerfile: ./Dockerfile 16 | image: "sample-app-client" 17 | ports: 18 | - "3000:3000" 19 | cors: 20 | build: 21 | context: Cors 22 | image: "sample-app-cors" 23 | ports: 24 | - "8090:8090" 25 | --------------------------------------------------------------------------------