Repository: gilbarbara/react-spotify-web-playback Branch: main Commit: a900bebf2c98 Files: 79 Total size: 287.5 KB Directory structure: gitextract_xgcv7u9v/ ├── .codesandbox/ │ └── ci.json ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ └── config.yml │ └── workflows/ │ └── main.yml ├── .gitignore ├── .husky/ │ ├── post-merge │ └── pre-commit ├── .prettierignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── demo/ │ ├── package.json │ ├── public/ │ │ ├── index.html │ │ └── manifest.json │ ├── src/ │ │ ├── App.tsx │ │ ├── GitHubRepo.tsx │ │ ├── components/ │ │ │ ├── GlobalStyles.tsx │ │ │ ├── Player.tsx │ │ │ ├── RepeatButton.tsx │ │ │ └── ShuffleButton.tsx │ │ ├── index.tsx │ │ ├── modules/ │ │ │ ├── helpers.ts │ │ │ └── theme.ts │ │ └── types.ts │ └── tsconfig.json ├── package.json ├── sonar-project.properties ├── src/ │ ├── components/ │ │ ├── Actions.tsx │ │ ├── ClickOutside.tsx │ │ ├── Controls.tsx │ │ ├── Devices.tsx │ │ ├── ErrorMessage.tsx │ │ ├── Info.tsx │ │ ├── Loader.tsx │ │ ├── Player.tsx │ │ ├── Slider.tsx │ │ ├── SpotifyLogo.tsx │ │ ├── Volume.tsx │ │ ├── Wrapper.tsx │ │ └── icons/ │ │ ├── Devices.tsx │ │ ├── DevicesComputer.tsx │ │ ├── DevicesMobile.tsx │ │ ├── DevicesSpeaker.tsx │ │ ├── Favorite.tsx │ │ ├── FavoriteOutline.tsx │ │ ├── Next.tsx │ │ ├── Pause.tsx │ │ ├── Play.tsx │ │ ├── Previous.tsx │ │ ├── VolumeHigh.tsx │ │ ├── VolumeLow.tsx │ │ ├── VolumeMid.tsx │ │ └── VolumeMute.tsx │ ├── constants.ts │ ├── index.tsx │ ├── modules/ │ │ ├── getters.ts │ │ ├── helpers.ts │ │ ├── hooks.ts │ │ ├── spotify.ts │ │ └── styled.tsx │ └── types/ │ ├── common.ts │ ├── index.ts │ └── spotify.ts ├── test/ │ ├── __setup__/ │ │ ├── global.d.ts │ │ └── vitest.setup.ts │ ├── __snapshots__/ │ │ ├── constants.spec.ts.snap │ │ └── index.spec.tsx.snap │ ├── constants.spec.ts │ ├── fixtures/ │ │ ├── data.ts │ │ └── helpers.ts │ ├── index.spec.tsx │ ├── modules/ │ │ ├── __snapshots__/ │ │ │ └── getters.spec.ts.snap │ │ ├── getters.spec.ts │ │ ├── helpers.spec.ts │ │ └── spotify.spec.ts │ └── tsconfig.json ├── tsconfig.json └── vitest.config.mts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .codesandbox/ci.json ================================================ { "node": "18", "sandboxes": ["/demo"] } ================================================ FILE: .editorconfig ================================================ # EditorConfig is awesome: http://EditorConfig.org # top-most EditorConfig file root = true # Unix-style newlines with a newline ending every file [*] end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 [*.{scss,sass}] indent_style = space indent_size = 2 ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: '🐛 Bug report' description: Report a reproducible bug or regression body: - type: markdown attributes: value: | Thank you for reporting an issue :pray:. This issue tracker is for reporting reproducible bugs or regression's found in [react-spotify-web-playback](https://github.com/gilbarbara/react-spotify-web-playback) If you have a question about how to achieve something and are struggling, please post a question inside of react-spotify-web-playback's [Discussions tab](https://github.com/gilbarbara/react-spotify-web-playback/discussions) Before submitting a new bug/issue, please check the links below to see if there is a solution or question posted there already: - [Discussions tab](https://github.com/gilbarbara/react-spotify-web-playback/discussions) - [Open Issues](https://github.com/gilbarbara/react-spotify-web-playback/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc) - [Closed Issues](https://github.com/gilbarbara/react-spotify-web-playback/issues?q=is%3Aissue+sort%3Aupdated-desc+is%3Aclosed) The more information you fill in, the better the community can help you. - type: textarea id: description attributes: label: Describe the bug description: Provide a clear and concise description of the challenge you are running into. validations: required: true - type: input id: link attributes: label: Your minimal, reproducible example description: | Please add a link to a minimal reproduction. Note: - Your bug may get fixed much faster if we can run your code. - To create a shareable code example for web, you can use CodeSandbox (https://codesandbox.io/s/new) or Stackblitz (https://stackblitz.com/). - Please make sure the example is complete and runnable - e.g. avoid localhost URLs. - Feel free to fork the demo CodeSandbox example to reproduce your issue: https://codesandbox.io/s/github/gilbarbara/react-spotify-web-playback/main/demo placeholder: | e.g. Code Sandbox, Stackblitz, etc. validations: required: true - type: textarea id: steps attributes: label: Steps to reproduce description: Describe the steps we have to take to reproduce the behavior. placeholder: | 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error validations: required: true - type: textarea id: expected attributes: label: Expected behavior description: Provide a clear and concise description of what you expected to happen. placeholder: | As a user, I expected ___ behavior but i am seeing ___ validations: required: true - type: dropdown attributes: label: How often does this bug happen? description: | Following the reproduction steps above, how easily are you able to reproduce this bug? options: - Every time - Often - Sometimes - Only once - type: textarea id: screenshots_or_videos attributes: label: Screenshots or Videos description: | If applicable, add screenshots or a video to help explain your problem. For more information on the supported file image/file types and the file size limits, please refer to the following link: https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/attaching-files placeholder: | You can drag your video or image files inside of this editor ↓ - type: textarea id: platform attributes: label: Platform description: | If the problem is specific to a platform, please let us know which one. placeholder: | - OS: [e.g. macOS, Windows, Linux, iOS, Android] - Browser: [e.g. Chrome, Safari, Firefox, React Native] - Version: [e.g. 91.1] - type: input id: rswp-version attributes: label: react-spotify-web-playback version description: | Please let us know the exact version of react-spotify-web-playback you were using when the issue occurred. Please don't just put in "latest", as this is subject to change. placeholder: | e.g. 0.14.0 validations: required: true - type: input id: ts-version attributes: label: TypeScript version description: | If you are using TypeScript, please let us know the exact version of TypeScript you were using when the issue occurred. placeholder: | e.g. 5.1.0 - type: input id: build-tool attributes: label: Build tool description: | If the issue is specific to a build tool, please let us know which one. placeholder: | e.g. webpack, vite, rollup, parcel, create-react-app, next.js, etc. - type: textarea id: additional attributes: label: Additional context description: Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: 🗣 Feature Request / Question / Help url: https://github.com/gilbarbara/react-spotify-web-playback/discussions/new about: How does it work with...? I have an idea... ================================================ FILE: .github/workflows/main.yml ================================================ name: CI on: push: branches: ['main'] tags: ['v*'] pull_request: branches: ['*'] workflow_dispatch: concurrency: group: ${{ github.ref }} cancel-in-progress: true jobs: main: name: Validate and Deploy runs-on: ubuntu-latest env: CI: true steps: - name: Setup timezone uses: zcong1993/setup-timezone@master with: timezone: America/Sao_Paulo - name: Setup repo uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: 22 registry-url: 'https://registry.npmjs.org' - name: Install pnpm uses: pnpm/action-setup@v3 with: version: 9 run_install: false - name: Get pnpm store directory shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm cache uses: actions/cache@v4 with: path: ${{ env.STORE_PATH }} key: "${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}" restore-keys: | ${{ runner.os }}-pnpm-store- - name: Install Packages run: pnpm install timeout-minutes: 3 - name: Validate and Build if: "!startsWith(github.ref, 'refs/tags/')" run: pnpm run validate timeout-minutes: 3 - name: SonarCloud Scan if: "!startsWith(github.ref, 'refs/tags/')" uses: SonarSource/sonarqube-scan-action@master env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - name: Publish Package if: startsWith(github.ref, 'refs/tags/') run: npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ================================================ FILE: .gitignore ================================================ .idea/ .tmp/ coverage/ demo/pnpm-lock.yaml dist/ node_modules/ ================================================ FILE: .husky/post-merge ================================================ ./node_modules/.bin/repo-tools install-packages ================================================ FILE: .husky/pre-commit ================================================ ./node_modules/.bin/repo-tools check-remote && npm run validate ================================================ FILE: .prettierignore ================================================ coverage lib node_modules ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to react-spotify-web-playback :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: **Reporting Bugs** Before creating bug reports, please check this [list](https://github.com/gilbarbara/react-spotify-web-playback/issues) as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible. **Pull Requests** Before submitting a new pull request, open a new issue to discuss it. It may already been implemented but not published or we might have found the same situation before and decide against it. In any case: - Format files using these rules [EditorConfig](https://github.com/gilbarbara/react-spotify-web-playback/blob/main/.editorconfig) - Follow the [ESLint](https://github.com/gilbarbara/eslint-config) styleguide. Thank you! ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019, Gil Barbara Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # react-spotify-web-playback [![npm version](https://badge.fury.io/js/react-spotify-web-playback.svg)](https://www.npmjs.com/package/react-spotify-web-playback) [![CI](https://github.com/gilbarbara/react-spotify-web-playback/actions/workflows/main.yml/badge.svg)](https://github.com/gilbarbara/react-spotify-web-playback/actions/workflows/main.yml) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=gilbarbara_react-spotify-web-playback&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=gilbarbara_react-spotify-web-playback) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=gilbarbara_react-spotify-web-playback&metric=coverage)](https://sonarcloud.io/summary/new_code?id=gilbarbara_react-spotify-web-playback) #### A Spotify player with [Spotify's Web Playback SDK](https://developer.spotify.com/documentation/web-playback-sdk/). View the [demo](https://react-spotify-web-playback.gilbarbara.dev/) Check the [supported browser](https://developer.spotify.com/documentation/web-playback-sdk/#supported-browsers) list. This library will try to use the user's devices to work with unsupported browsers. ## Setup ```bash npm i react-spotify-web-playback ``` ## Getting Started ```jsx import SpotifyPlayer from 'react-spotify-web-playback'; ; ``` ### Client-side only This library requires the `window` object. If you are using an SSR framework, you'll need to use a [dynamic import](https://nextjs.org/docs/advanced-features/dynamic-import) or a [Client Component](https://beta.nextjs.org/docs/rendering/server-and-client-components#client-components) to load the player. ## Spotify Token It needs a Spotify token with the following scopes: - streaming - user-read-email - user-read-private - user-read-playback-state (to read other devices' status) - user-modify-playback-state (to update other devices) If you want to show the Favorite button (💚), you'll need the additional scopes: - user-library-read - user-library-modify Please refer to Spotify's Web API [docs](https://developer.spotify.com/documentation/web-api/) for more information. > This library doesn't handle token generation and expiration. You'll need to handle that by yourself. ## Props **callback** `(state: CallbackState) => void` Get status updates from the player.
Type Definition ```typescript type ErrorType = 'account' | 'authentication' | 'initialization' | 'playback' | 'player'; type RepeatState = 'off' | 'context' | 'track'; type Status = 'ERROR' | 'IDLE' | 'INITIALIZING' | 'READY' | 'RUNNING' | 'UNSUPPORTED'; type Type = | 'device_update' | 'favorite_update' | 'player_update' | 'progress_update' | 'status_update' | 'track_update'; interface CallbackState extends State { type: Type; } interface State { currentDeviceId: string; deviceId: string; devices: SpotifyDevice[]; error: string; errorType: ErrorType | null; isActive: boolean; isInitializing: boolean; isMagnified: boolean; isPlaying: boolean; isSaved: boolean; isUnsupported: boolean; needsUpdate: boolean; nextTracks: SpotifyTrack[]; playerPosition: 'bottom' | 'top'; position: number; previousTracks: SpotifyTrack[]; progressMs: number; repeat: RepeatState; shuffle: boolean; status: Status; track: SpotifyTrack; volume: number; } ```
**components** `CustomComponents` Custom components for the player.
Type Definition ```typescript interface CustomComponents { /** * A React component to be displayed before the previous button. */ leftButton?: ReactNode; /** * A React component to be displayed after the next button. */ rightButton?: ReactNode; } ```
**getOAuthToken** `(callback: (token: string) => void) => Promise` The callback [Spotify SDK](https://developer.spotify.com/documentation/web-playback-sdk/reference/#initializing-the-sdk) uses to get/update the token. _Use it to generate a new token when the player needs it._
Example ```tsx import { useState } from 'react'; import SpotifyPlayer, { Props } from 'react-spotify-web-playback'; import { refreshTokenRequest } from '../some_module'; export default function PlayerWrapper() { const [accessToken, setAccessToken] = useState(''); const [refreshToken, setRefreshToken] = useState(''); const [expiresAt, setExpiresAt] = useState(0); const getOAuthToken: Props['getOAuthToken'] = async callback => { if (expiresAt > Date.now()) { callback(accessToken); return; } const { acess_token, expires_in, refresh_token } = await refreshTokenRequest(refreshToken); setAccessToken(acess_token); setRefreshToken(refresh_token); setExpiresAt(Date.now() + expires_in * 1000); callback(acess_token); }; return ; } ```
**getPlayer** `(player: SpotifyPlayer) => void` Get the Spotify Web Playback SDK instance. **hideAttribution** `boolean` ▶︎ false Hide the Spotify logo. **hideCoverArt** `boolean` ▶︎ false Hide the cover art **initialVolume** `number` between 0 and 1. ▶︎ 1 The initial volume for the player. It's not used for external devices. **inlineVolume** `boolean` ▶︎ true Show the volume inline for the "responsive" layout for 768px and above. **layout** `'compact' | 'responsive'` ▶︎ 'responsive' The layout of the player. **locale** `Locale` The strings used for aria-label/title attributes.
Type Definition ```typescript interface Locale { currentDevice?: string; // 'Current device' devices?: string; // 'Devices' next?: string; // 'Next' otherDevices?: string; // 'Select other device' pause?: string; // 'Pause' play?: string; // 'Play' previous?: string; // 'Previous' removeTrack?: string; // 'Remove from your favorites' saveTrack?: string; // 'Save to your favorites' title?: string; // '{name} on SPOTIFY' volume?: string; // 'Volume' } ```
**magnifySliderOnHover**: `boolean` ▶︎ false Magnify the player's slider on hover. **name** `string` ▶︎ 'Spotify Web Player' The name of the player. **offset** `number` The position of the list/tracks you want to start the player. **persistDeviceSelection** `boolean` ▶︎ false Save the device selection. **play** `boolean` Control the player's status. **preloadData** `boolean` Preload the track data before playing. **showSaveIcon** `boolean` ▶︎ false Display a Favorite button. It needs additional scopes in your token. **styles** `object` Customize the player's appearance. Check `StylesOptions` in the [types](src/types/common.ts). **syncExternalDevice** `boolean` ▶︎ false Use the external player context if there are no URIs and an external device is playing. **syncExternalDeviceInterval** `number` ▶︎ 5 The time in seconds that the player will sync with external devices. **token** `string` **REQUIRED** A Spotify token. More info is below. **updateSavedStatus** `(fn: (status: boolean) => any) => any` Provide you with a function to sync the track saved status in the player. _This works in addition to the **showSaveIcon** prop, and it is only needed if you keep track's saved status in your app._ **uris** `string | string[]` **REQUIRED** A list of Spotify [URIs](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids). ## Spotify API The functions that interact with the Spotify API are exported for your convenience. Use them at your own risk. ```tsx import { spotifyApi } from 'react-spotify-web-playback'; ``` **checkTracksStatus(token: string, tracks: string | string[]): Promise\** **getAlbumTracks(token: string, id: string): Promise\** **getArtistTopTracks(token: string, id: string): Promise\** **getDevices(token: string): Promise\** **getPlaybackState(token: string): Promise\** **getPlaylistTracks(token: string, id: string): Promise\** **getQueue(token: string): Promise\** **getShow(token: string, id: string): Promise\** **getShowEpisodes(token: string, id: string, offset = 0): Promise\** **getTrack(token: string, id: string): Promise\** **pause(token: string, deviceId?: string): Promise\** **play(token: string, options: SpotifyPlayOptions): Promise\** ```typescript interface SpotifyPlayOptions { context_uri?: string; deviceId: string; offset?: number; uris?: string[]; } ``` **previous(token: string, deviceId?: string): Promise\** **next(token: string, deviceId?: string): Promise\** **removeTracks(token: string, tracks: string | string[]): Promise\** **repeat(token: string, state: 'context' | 'track' | 'off', deviceId?: string): Promise\** **saveTracks(token: string, tracks: string | string[]): Promise\** **seek(token: string, position: number, deviceId?: string): Promise\** **setDevice(token: string, deviceId: string, shouldPlay?: boolean): Promise\** **setVolume(token: string, volume: number, deviceId?: string): Promise\** **shuffle(token: string, state: boolean, deviceId?: string): Promise\** ## Styling You can customize the UI with a `styles` prop. If you want a transparent player, you can use `bgColor: 'transparent'`. Check all the available options [here](src/types/common.ts#L195). ```tsx ``` ![rswp-styles](https://files.gilbarbara.dev/media/rswp-v2.png) ## Issues If you find a bug, please file an issue on [our issue tracker on GitHub](https://github.com/gilbarbara/react-spotify-web-playback/issues). ## License MIT ================================================ FILE: demo/package.json ================================================ { "name": "react-spotify-web-playback-demo", "version": "0.14.4", "description": "Demo for react-spotify-web-playback", "keywords": [], "main": "src/index.tsx", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@gilbarbara/hooks": "^0.9.0", "@gilbarbara/components": "^0.15.1", "@gilbarbara/cookies": "^1.0.1", "@gilbarbara/eslint-config": "^0.8.4", "@gilbarbara/helpers": "^0.9.5", "@gilbarbara/prettier-config": "^1.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-scripts": "^5.0.1", "react-spotify-web-playback": "latest" }, "devDependencies": { "@types/node": "^22.10.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "typescript": "5.7.3" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" }, "eslintConfig": { "extends": "@gilbarbara/eslint-config" }, "prettier": "@gilbarbara/prettier-config", "browserslist": [ ">0.2%", "not dead", "not ie <= 11", "not op_mini all" ] } ================================================ FILE: demo/public/index.html ================================================ React Spotify Web Playback
================================================ FILE: demo/public/manifest.json ================================================ { "short_name": "React App", "name": "Create React App Sample", "icons": [ { "src": "favicon.ico", "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" } ], "start_url": ".", "display": "standalone", "theme_color": "#000000", "background_color": "#ffffff" } ================================================ FILE: demo/src/App.tsx ================================================ import { DragEventHandler, FormEvent, MouseEvent, ReactNode, useCallback, useEffect, useRef, } from 'react'; import SpotifyWebPlayer, { CallbackState, ERROR_TYPE, Layout, RepeatState, SpotifyPlayer, STATUS, StylesProps, TYPE, Type, } from 'react-spotify-web-playback'; import { Anchor, Box, Button, ButtonGroup, ButtonUnstyled, Container, Flex, FormElementWrapper, H1, H4, Icon, Input, Loader, NonIdealState, Paragraph, Spacer, Toggle, } from '@gilbarbara/components'; import { request } from '@gilbarbara/helpers'; import { useEffectOnce, useSetState } from '@gilbarbara/hooks'; import GlobalStyles from './components/GlobalStyles'; import Player from './components/Player'; import RepeatButton from './components/RepeatButton'; import ShuffleButton from './components/ShuffleButton'; import { getAuthorizeUrl, getCredentials, login, logout, parseURIs, refreshCredentials, setCredentials, } from './modules/helpers'; interface State { accessToken: string; error?: string; hideAttribution: boolean; inlineVolume: boolean; isActive: boolean; isPlaying: boolean; layout: 'responsive' | 'compact'; player: SpotifyPlayer | null; refreshToken: string; repeat: RepeatState; shuffle: boolean; styles?: StylesProps; transparent: boolean; URIs: string[]; } const baseURIs = { // album: 'spotify:album:0WLIcGHr0nLyKJpMirAS17', // The Breathing Effect - Mars Is A Very Bad Place For Love album: 'spotify:album:4c7fP0tUymaZcrEFIeIeZc', // Caribou - Honey // artist: 'spotify:artist:4oLeXFyACqeem2VImYeBFe', // Fred Again.. artist: 'spotify:artist:7A0awCXkE1FtSU8B0qwOJQ', // Jamie xx // playlist: 'spotify:playlist:1Zr2FUPeD5hYJTGbTDSQs4', // Rework playlist: 'spotify:playlist:3h7lEfRkEdtVvGJTdTAudn', // Nation show: 'spotify:show:4kYCRYJ3yK5DQbP5tbfZby', tracks: [ // Boogie // 'spotify:track:3zYpRGnnoegSpt3SguSo3W', // 'spotify:track:5sjeJXROHuutyj8P3JGZoN', // 'spotify:track:3u0VPnYkZo30zw60SInouA', // 'spotify:track:5ZoDwIP1ntHwciLjydJ8X2', // 'spotify:track:7ohR0qPH6f2Vuj2pUNanJG', // 'spotify:track:5g2sPpVq3hdk9ZuMfABrts', // 'spotify:track:3mJ6pNcFM2CkykCYSREdKT', // 'spotify:track:63DTXKZi7YdJ4tzGti1Dtr', // 90s Electronic // 'spotify:track:5Kh3pqvJGVCBapAgrRP8QO', // 'spotify:track:0j5FJJOmmnXPd0XajFWkMF', // 'spotify:track:3XWgwgbWDI56mf1Wl3cLzb', // 'spotify:track:6rvinglzwGWPaO9N9nnHeR', // 'spotify:track:6LERtd1yiclxFH8MHAqr0Q', // 'spotify:track:5eFCFpmDbqGqpdOVE9CXCh', // 'spotify:track:1RdHfWJogQm1UW4MglA8gA', // 'spotify:track:3z70bimZB3dgdixBrxpxY0', // 'spotify:track:3RmCwMliRzxvjGp42ItZtC', // 'spotify:track:6WpTrVTG1mFU1hZpxbVBX7', // 'spotify:track:5sJiLlgQKBL81QCTOkoLB5', // 'spotify:track:7hnqJYCKZFW7vMoykaraZG', // Dance Punk 'spotify:track:305CEVdhAViS0CW2NCLvdR', 'spotify:track:1XlDNpWy8dyEljyRd0RC2J', 'spotify:track:1Jd9W7k8DTnBSovDSxK77n', 'spotify:track:7ddGC67DasWO30q5YepUJe', 'spotify:track:3yRV0V5l87Q6EyEnv3d7YJ', 'spotify:track:7pskYSHhRTH1TFtVdQevG5', 'spotify:track:3RCj5fG55qjtmnEML1gpnA', 'spotify:track:6b9oxWgxekphG5vkz8ZpBt', 'spotify:track:29wCKit7yf8ipSCViR7cGd', 'spotify:track:0Nua2OtL0ygR9HrY50ptQX', 'spotify:track:2Yx9fXTpx1cxL6m4cMq9AO', 'spotify:track:4d2sFYYGe1vQ65IXwm6mNt', ], }; function App() { const URIsInput = useRef(null); const isMounted = useRef(false); const playerRef = useRef(null); const code = new URLSearchParams(window.location.search).get('code'); const credentials = getCredentials(); const [ { accessToken, error, hideAttribution, inlineVolume, isActive, isPlaying, layout, refreshToken, repeat, shuffle, styles, transparent, URIs, }, setState, ] = useSetState({ accessToken: credentials.accessToken ?? '', hideAttribution: false, inlineVolume: true, isActive: false, isPlaying: false, layout: 'responsive', player: null, refreshToken: credentials.refreshToken ?? '', repeat: 'off', shuffle: false, styles: undefined, transparent: false, URIs: [baseURIs.artist], }); useEffectOnce(() => { if (code && !isMounted.current) { login(code) .then(spotifyCredentials => { setCredentials(spotifyCredentials); setState({ accessToken: spotifyCredentials.accessToken, refreshToken: spotifyCredentials.refreshToken, }); }) .catch(fetchError => { setState({ error: fetchError.message || 'An error occurred. Try again' }); }) .finally(() => { const url = new URL(window.location.href); window.history.replaceState({}, document.title, `${url.pathname}`); }); } return () => { isMounted.current = true; }; }); useEffect(() => { if (!playerRef.current) { return; } playerRef.current.querySelector('a')?.setAttribute('draggable', `${layout === 'responsive'}`); playerRef .current.querySelector('img') ?.setAttribute('draggable', `${layout === 'responsive'}`); if (layout === 'responsive') { playerRef.current.style.left = '0'; playerRef.current.style.right = '0'; playerRef.current.style.bottom = '0'; playerRef.current.style.top = 'auto'; } else { playerRef.current.style.left = 'auto'; playerRef.current.style.right = '20px'; playerRef.current.style.bottom = '20px'; playerRef.current.style.top = 'auto'; } }, [layout]); useEffect(() => { const dragOver = (event: DragEvent) => { event.preventDefault(); if (!event.dataTransfer) { return; } event.dataTransfer.dropEffect = 'move'; }; const drop = (event: DragEvent) => { event.preventDefault(); const offsetData = event.dataTransfer?.getData('offset'); if (!offsetData) { return; } const offset = JSON.parse(offsetData); const xPos = event.clientX - offset.x; const yPos = event.clientY - offset.y; if (playerRef.current) { playerRef.current.style.left = `${xPos}px`; playerRef.current.style.top = `${yPos}px`; playerRef.current.style.bottom = 'auto'; playerRef.current.style.right = 'auto'; } }; document.documentElement.addEventListener('dragover', dragOver); document.documentElement.addEventListener('drop', drop); return () => { document.documentElement.removeEventListener('dragover', dragOver); document.documentElement.removeEventListener('drop', drop); }; }, []); const handleSubmitURIs = useCallback( (event: FormEvent) => { event.preventDefault(); if (URIsInput?.current) { setState({ URIs: parseURIs(URIsInput.current.value) }); } }, [setState], ); const handleClickLogout = useCallback(() => { logout(); setState({ accessToken: '', refreshToken: '' }); }, [setState]); const handleClickURIs = useCallback( (event: MouseEvent) => { event.preventDefault(); const { uris = '' } = event.currentTarget.dataset; setState({ isPlaying: true, URIs: parseURIs(uris) }); if (URIsInput?.current) { URIsInput.current.value = uris; } }, [setState], ); const handleCallback = useCallback( async ({ track, type, ...state }: CallbackState) => { /* eslint-disable no-console */ console.group(`RSWP: ${type}`); console.log(state); console.groupEnd(); /* eslint-enable no-console */ if (type === TYPE.PLAYER) { setState({ isActive: state.isActive, isPlaying: state.isPlaying, repeat: state.repeat, shuffle: state.shuffle, }); } if (([TYPE.PRELOAD, TYPE.TRACK] as Array).includes(type)) { const trackStyles = await request( `https://scripts.gilbarbara.dev/api/getImagePlayerStyles?url=${track.image}`, ); if (transparent) { trackStyles.bgColor = 'transparent'; } setState({ styles: trackStyles }); } if (state.status === STATUS.ERROR && state.errorType === ERROR_TYPE.AUTHENTICATION) { refreshCredentials(refreshToken) .then(spotifyCredentials => { setCredentials(spotifyCredentials); setState({ accessToken: spotifyCredentials.accessToken }); }) .catch(() => { logout(); setState({ accessToken: '', refreshToken: '' }); }); } }, [refreshToken, setState, transparent], ); const handlePlayerDrag: DragEventHandler = useCallback( event => { if (layout === 'responsive') { return; } const boundingRect = playerRef.current?.getBoundingClientRect() ?? { left: 0, top: 0 }; const offset = { x: event.clientX - boundingRect.left, y: event.clientY - boundingRect.top, }; event.dataTransfer.setData('offset', JSON.stringify(offset)); }, [layout], ); const getPlayer = useCallback( async (playerInstance: SpotifyPlayer) => { setState({ player: playerInstance }); }, [setState], ); const content: Record = { connect: ( ), }; const getButtonStyle = (input: string) => { return URIs.join(',') === input ? 'primary.300' : 'primary'; }; if (error) { content.main = ( <> {content.connect} ); } else if (code) { content.main = ; } else if (accessToken) { content.main = ( <> } > Jamie xx Caribou - Honey Nation Dance Punk Syntax

Layout

setState({ layout: event.currentTarget.textContent as Layout })} selected={layout} size="sm" />

Props

setState({ hideAttribution: !hideAttribution })} /> setState({ inlineVolume: !inlineVolume })} /> setState({ transparent: value })} />
); content.player = ( {layout === 'compact' && ( )} ), rightButton: , }} getOAuthToken={async callback => { if ((credentials.expiresAt ?? 0) < Math.round(Date.now() / 1000)) { const newCredentials = await refreshCredentials(refreshToken); setCredentials(newCredentials); setState({ accessToken: newCredentials.accessToken, refreshToken: newCredentials.refreshToken, }); callback(newCredentials.accessToken); } else { callback(accessToken); } }} getPlayer={getPlayer} hideAttribution={hideAttribution} initialVolume={100} inlineVolume={inlineVolume} layout={layout} persistDeviceSelection play={isPlaying} preloadData showSaveIcon styles={transparent ? { ...styles, bgColor: 'transparent' } : styles} syncExternalDevice token={accessToken} uris={URIs} /> ); } else { content.main = content.connect; } return ( <>

React Spotify Web Playback

{accessToken && ( )}
{content.main} {content.player}
); } export default App; ================================================ FILE: demo/src/GitHubRepo.tsx ================================================ import styled from '@emotion/styled'; import { primaryColor } from './modules/theme'; const Wrapper = styled.a` position: fixed; top: 0; right: 0; &:hover { .octo-arm { animation: octocat-wave 560ms ease-in-out; } } svg { fill: ${primaryColor}; color: #fff; } .octo-arm { transform-origin: 130px 106px; } @keyframes octocat-wave { 0%, 100% { transform: rotate(0); } 20%, 60% { transform: rotate(-25deg); } 40%, 80% { transform: rotate(10deg); } } `; function GitHubRepo() { return ( ); } export default GitHubRepo; ================================================ FILE: demo/src/components/GlobalStyles.tsx ================================================ import { css, Global } from '@emotion/react'; import { theme } from '@gilbarbara/components'; // background: linear-gradient( // 0deg, // oklch(0.65 0.3 29.62 / 0.8), // oklch(0.65 0.3 29.62 / 0) 75% // ), // linear-gradient(60deg, oklch(0.96 0.25 110.23 / 0.8), oklch(0.96 0.25 110.23 / 0) 75%), // linear-gradient(120deg, oklch(0.85 0.36 144.24 / 0.8), oklch(0.85 0.36 144.24 / 0) 75%), // linear-gradient(180deg, oklch(0.89 0.2 194.59 / 0.8), oklch(0.89 0.2 194.18 / 0) 75%), // linear-gradient(240deg, oklch(0.47 0.32 264.05 / 0.8), oklch(0.47 0.32 264.05 / 0) 75%), // linear-gradient(300deg, oklch(0.7 0.35 327.92 / 0.8), oklch(0.7 0.35 327.92 / 0) 75%); export default function GlobalStyles({ hasToken }: any) { return ( ); } ================================================ FILE: demo/src/components/Player.tsx ================================================ import { Layout } from 'react-spotify-web-playback'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; const Player = styled.div<{ layout: Layout }>(({ layout }) => { if (layout === 'responsive') { return css` position: fixed; bottom: 0; left: 0; right: 0; `; } return css` border-radius: 12px; box-shadow: 0 0 20px rgba(0, 0, 0, 0.2); bottom: 20px; overflow: hidden; position: fixed; right: 20px; width: 320px; > button { position: absolute; top: 8px; right: 8px; z-index: 100; } `; }); export default Player; ================================================ FILE: demo/src/components/RepeatButton.tsx ================================================ import { ComponentProps, useCallback } from 'react'; import { RepeatState, spotifyApi } from 'react-spotify-web-playback'; import { ButtonUnstyled, FlexInline, Icon } from '@gilbarbara/components'; export default function RepeatButton({ repeat, token, ...rest }: Omit, 'children'> & { repeat: RepeatState; token: string; }) { const handleClick = useCallback(async () => { let value: RepeatState = 'off'; if (repeat === 'off') { value = 'track'; } else if (repeat === 'context') { value = 'track'; } await spotifyApi.repeat(token, value); }, [repeat, token]); let title = 'Enable repeat'; if (repeat === 'track') { title = 'Disable repeat'; } if (repeat === 'context') { title = 'Enable repeat one'; } return ( {repeat !== 'off' && ( {repeat === 'track' ? '1' : 'all'} )} ); } ================================================ FILE: demo/src/components/ShuffleButton.tsx ================================================ import { ComponentProps, useCallback } from 'react'; import { spotifyApi } from 'react-spotify-web-playback'; import { ButtonUnstyled, FlexInline, Icon } from '@gilbarbara/components'; export default function ShuffleButton({ shuffle, token, ...rest }: Omit, 'children'> & { shuffle: boolean; token: string; }) { const handleClick = useCallback(async () => { await spotifyApi.shuffle(token, !shuffle); }, [shuffle, token]); return ( {shuffle && ( )} ); } ================================================ FILE: demo/src/index.tsx ================================================ import { ThemeProvider } from '@emotion/react'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; import { theme } from './modules/theme'; const rootElement = document.getElementById('root'); if (rootElement) { const root = createRoot(rootElement); root.render( , ); } ================================================ FILE: demo/src/modules/helpers.ts ================================================ import { getCookie, removeCookie, setCookie } from '@gilbarbara/cookies'; import { MONTH, request } from '@gilbarbara/helpers'; import { SpotifyCredentials } from '../types'; const COOKIE_NAME = 'RSWP_TOKENS'; const { NODE_ENV } = process.env; export const SPOTIFY = { accountApiUrl: 'https://accounts.spotify.com', clientId: '2030beede5174f9f9b23ffc23ba0705c', redirectUri: NODE_ENV === 'production' ? 'https://react-spotify-web-playback.gilbarbara.dev' : 'http://localhost:3000', scopes: [ 'streaming', 'user-read-email', 'user-read-private', 'user-library-read', 'user-library-modify', 'user-read-playback-state', 'user-modify-playback-state', ], }; export const API_URL = 'https://scripts.gilbarbara.dev/api'; export function getAuthorizeUrl() { const parameters = { client_id: SPOTIFY.clientId, response_type: 'code', redirect_uri: SPOTIFY.redirectUri, scope: SPOTIFY.scopes.join(' '), state: 'auth', }; return `${SPOTIFY.accountApiUrl}/authorize?${new URLSearchParams(parameters)}`; } export function getCredentials() { const tokens = getCookie(COOKIE_NAME); if (tokens) { return JSON.parse(tokens) as SpotifyCredentials; } return {} as Partial; } export function setCredentials(credentials: SpotifyCredentials) { setCookie(COOKIE_NAME, JSON.stringify(credentials), { expires: MONTH * 6 }); } export async function login(code: string) { return request(`${API_URL}/spotifyGetUserCredentials`, { method: 'POST', body: { code, redirectUri: SPOTIFY.redirectUri }, }); } export function refreshCredentials(refreshToken: string) { return request(`${API_URL}/spotifyRefreshToken`, { method: 'POST', body: { refreshToken }, }); } export function logout() { removeCookie(COOKIE_NAME); } export function parseURIs(input: string): string[] { const ids = input.split(','); return ids.every(d => validateURI(d)) ? ids : []; } export function validateURI(input: string): boolean { let isValid = false; if (input?.includes(':')) { const [key, type, id] = input.split(':'); if (key && type && type !== 'user' && id && id.length === 22) { isValid = true; } } return isValid; } ================================================ FILE: demo/src/modules/theme.ts ================================================ import { mergeTheme } from '@gilbarbara/components'; export const primaryColor = '#ff6d57'; export const theme = mergeTheme({ darkMode: true, colors: { primary: primaryColor, }, }); ================================================ FILE: demo/src/types.ts ================================================ export interface SpotifyCredentials { accessToken: string; expiresAt: number; refreshToken?: string; scope: string[]; } ================================================ FILE: demo/tsconfig.json ================================================ { "compilerOptions": { "esModuleInterop": true, "isolatedModules": true, "jsx": "react-jsx", "lib": ["dom", "dom.iterable", "esnext"], "skipLibCheck": true, "strict": true, "target": "es5" }, "include": ["src/**/*"] } ================================================ FILE: package.json ================================================ { "name": "react-spotify-web-playback", "version": "0.14.7", "description": "A React Spotify Web Player", "author": "Gil Barbara ", "repository": { "type": "git", "url": "git://github.com/gilbarbara/react-spotify-web-playback.git" }, "bugs": { "url": "https://github.com/gilbarbara/react-spotify-web-playback/issues" }, "homepage": "https://github.com/gilbarbara/react-spotify-web-playback#readme", "main": "./dist/index.js", "module": "./dist/index.mjs", "exports": { "import": "./dist/index.mjs", "require": "./dist/index.js" }, "files": [ "dist", "src" ], "types": "dist/index.d.ts", "sideEffects": true, "license": "MIT", "keywords": [ "react", "react-component", "spotify", "player", "web playback" ], "peerDependencies": { "react": "17 - 19" }, "dependencies": { "@gilbarbara/deep-equal": "^0.3.1", "@gilbarbara/react-range-slider": "^0.7.0", "@types/spotify-api": "^0.0.25", "@types/spotify-web-playback-sdk": "^0.1.19", "colorizr": "^3.0.7", "memoize-one": "^6.0.0", "nano-css": "^5.6.2" }, "devDependencies": { "@arethetypeswrong/cli": "^0.17.3", "@gilbarbara/eslint-config": "^0.8.4", "@gilbarbara/hooks": "^0.9.0", "@gilbarbara/prettier-config": "^1.0.0", "@gilbarbara/tsconfig": "^0.2.3", "@size-limit/file": "^11.1.6", "@swc/core": "^1.10.7", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@types/exenv": "^1.2.2", "@types/node": "^22.10.6", "@types/once": "^1.4.5", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react-swc": "^3.7.2", "@vitest/coverage-v8": "^2.1.8", "del-cli": "^6.0.0", "fix-tsup-cjs": "^1.2.0", "husky": "^9.1.7", "is-ci-cli": "^2.2.0", "jest-extended": "^4.0.2", "jsdom": "^26.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "repo-tools": "^0.3.1", "size-limit": "^11.1.6", "ts-node": "^10.9.2", "tsup": "^8.3.5", "typescript": "^5.7.3", "vite-tsconfig-paths": "^5.1.4", "vitest": "^2.1.8", "vitest-fetch-mock": "^0.4.3" }, "scripts": { "build": "npm run clean && tsup && fix-tsup-cjs", "clean": "del dist/*", "watch": "tsup --watch", "lint": "eslint --fix src test", "test": "is-ci \"test:coverage\" \"test:watch\"", "test:coverage": "vitest run --coverage", "test:watch": "vitest watch", "typecheck": "tsc -p test/tsconfig.json", "typevalidation": "attw -P", "format": "prettier \"**/*.{js,jsx,json,yml,yaml,css,less,scss,ts,tsx,md,graphql,mdx}\" --write", "validate": "npm run lint && npm run typecheck && npm run test:coverage && npm run build && npm run typevalidation && npm run size", "size": "size-limit", "prepublishOnly": "npm run validate", "prepare": "husky" }, "tsup": { "dts": true, "entry": [ "src/index.tsx" ], "format": [ "cjs", "esm" ], "sourcemap": true, "splitting": false }, "eslintConfig": { "extends": [ "@gilbarbara/eslint-config", "@gilbarbara/eslint-config/vitest", "@gilbarbara/eslint-config/testing-library" ], "overrides": [ { "files": [ "test/**/*.ts?(x)" ], "rules": { "no-console": "off" } } ], "rules": { "@typescript-eslint/no-non-null-assertion": "off", "react/sort-comp": "off", "unicorn/prefer-includes": "off" } }, "eslintIgnore": [ "demo" ], "prettier": "@gilbarbara/prettier-config", "size-limit": [ { "name": "commonjs", "path": "./dist/index.js", "limit": "20 KB" }, { "name": "esm", "path": "./dist/index.mjs", "limit": "20 KB" } ] } ================================================ FILE: sonar-project.properties ================================================ sonar.projectKey=gilbarbara_react-spotify-web-playback sonar.organization=gilbarbara-github sonar.source=./src sonar.javascript.lcov.reportPaths=./coverage/lcov.info sonar.exclusions=**/demo/**/*.*,**/test/**/*.* sonar.coverage.exclusions=**/test/**/*.*,**/vitest.config.mts ================================================ FILE: src/components/Actions.tsx ================================================ import { memo, ReactNode } from 'react'; import { CssLikeObject, px, styled } from '~/modules/styled'; import { Layout, StyledProps, StylesOptions } from '~/types'; interface Props { children: ReactNode; layout: Layout; styles: StylesOptions; } const Wrapper = styled('div')( { alignItems: 'center', display: 'flex', justifyContent: 'flex-end', 'pointer-events': 'none', }, ({ style }: StyledProps) => { let styles: CssLikeObject = { bottom: 0, position: 'absolute', right: 0, width: 'auto', }; if (style.layout === 'responsive') { styles = { '@media (max-width: 767px)': styles, '@media (min-width: 768px)': { height: px(style.h), }, }; } return { height: px(32), ...styles, }; }, 'ActionsRSWP', ); function Actions(props: Props) { const { children, layout, styles } = props; return ( {children} ); } export default memo(Actions); ================================================ FILE: src/components/ClickOutside.tsx ================================================ import { memo, ReactNode, useEffect, useRef } from 'react'; interface Props { children: ReactNode; isActive: boolean; onClick: () => void; } function ClickOutside(props: Props) { const { children, isActive, onClick, ...rest } = props; const containerRef = useRef(null); const isTouch = useRef(false); const handleClick = useRef((event: MouseEvent | TouchEvent) => { const container = containerRef.current; if (event.type === 'touchend') { isTouch.current = true; } if (event.type === 'click' && isTouch.current) { return; } if (container && !container.contains(event.target as Node)) { onClick(); } }); useEffect(() => { const { current } = handleClick; if (isActive) { document.addEventListener('touchend', current, true); document.addEventListener('click', current, true); } return () => { document.removeEventListener('touchend', current, true); document.removeEventListener('click', current, true); }; }, [isActive]); return (
{children}
); } export default memo(ClickOutside); ================================================ FILE: src/components/Controls.tsx ================================================ import { memo } from 'react'; import { CssLikeObject, px, styled } from '~/modules/styled'; import { CustomComponents, Layout, Locale, SpotifyTrack, StyledProps, StylesOptions, } from '~/types'; import Next from './icons/Next'; import Pause from './icons/Pause'; import Play from './icons/Play'; import Previous from './icons/Previous'; import Slider from './Slider'; interface Props { components?: CustomComponents; devices: JSX.Element | null; durationMs: number; isActive: boolean; isExternalDevice: boolean; isMagnified: boolean; isPlaying: boolean; layout: Layout; locale: Locale; nextTracks: SpotifyTrack[]; onChangeRange: (position: number) => void; onClickNext: () => void; onClickPrevious: () => void; onClickTogglePlay: () => void; onToggleMagnify: () => void; position: number; progressMs: number; styles: StylesOptions; volume: JSX.Element | null; } const Wrapper = styled('div')( { '.rswp__volume': { position: 'absolute', right: 0, top: 0, }, '.rswp__devices': { position: 'absolute', left: 0, top: 0, }, }, ({ style }: StyledProps) => { const isCompactLayout = style.layout === 'compact'; const styles: CssLikeObject = {}; if (isCompactLayout) { styles.padding = px(8); } else { styles.padding = `${px(4)} 0`; styles['@media (max-width: 767px)'] = { padding: px(8), }; } return styles; }, 'ControlsRSWP', ); const Buttons = styled('div')( { alignItems: 'center', display: 'flex', justifyContent: 'center', marginBottom: px(8), position: 'relative', '> div': { alignItems: 'center', display: 'flex', minWidth: px(32), textAlign: 'center', }, }, ({ style }: StyledProps) => ({ color: style.c, }), 'ControlsButtonsRSWP', ); const Button = styled('button')( { alignItems: 'center', display: 'inline-flex', fontSize: px(16), height: px(32), justifyContent: 'center', width: px(32), '&:disabled': { cursor: 'default', opacity: 0.6, }, '&.rswp__toggle': { fontSize: px(32), width: px(48), }, }, () => ({}), 'ControlsButtonRSWP', ); function Controls(props: Props) { const { components: { leftButton, rightButton } = {}, devices, durationMs, isActive, isExternalDevice, isMagnified, isPlaying, layout, locale, nextTracks, onChangeRange, onClickNext, onClickPrevious, onClickTogglePlay, onToggleMagnify, position, progressMs, styles, volume, } = props; const { color } = styles; return ( {devices &&
{devices}
}
{leftButton}
{rightButton}
{volume &&
{volume}
}
); } export default memo(Controls); ================================================ FILE: src/components/Devices.tsx ================================================ import { MouseEvent, useCallback, useState } from 'react'; import { CssLikeObject } from 'nano-css'; import { px, styled } from '~/modules/styled'; import { Layout, Locale, SpotifyDevice, StyledProps, StylesOptions } from '~/types'; import ClickOutside from './ClickOutside'; import DevicesIcon from './icons/Devices'; import DevicesComputerIcon from './icons/DevicesComputer'; import DevicesMobileIcon from './icons/DevicesMobile'; import DevicesSpeakerIcon from './icons/DevicesSpeaker'; interface DeviceList { currentDevice: SpotifyDevice | null; otherDevices: SpotifyDevice[]; } interface Props { currentDeviceId?: string; deviceId?: string; devices: SpotifyDevice[]; layout: Layout; locale: Locale; onClickDevice: (deviceId: string) => any; open: boolean; playerPosition: string; styles: StylesOptions; } const Wrapper = styled('div')( { 'pointer-events': 'all', alignItems: 'center', display: 'flex', justifyContent: 'center', position: 'relative', zIndex: 20, '> div': { backgroundColor: '#000', borderRadius: px(8), color: '#fff', filter: 'drop-shadow(1px 1px 6px rgba(0, 0, 0, 0.5))', fontSize: px(14), padding: px(16), position: 'absolute', textAlign: 'left', '> p': { fontWeight: 'bold', marginBottom: px(8), marginTop: px(16), whiteSpace: 'nowrap', }, button: { alignItems: 'center', display: 'flex', whiteSpace: 'nowrap', width: '100%', '&:not(:last-of-type)': { marginBottom: px(12), }, span: { display: 'inline-block', marginLeft: px(4), }, }, '> span': { background: 'transparent', borderLeft: `6px solid transparent`, borderRight: `6px solid transparent`, content: '""', display: 'block', height: 0, position: 'absolute', width: 0, }, }, '> button': { alignItems: 'center', display: 'flex', fontSize: px(24), height: px(32), justifyContent: 'center', width: px(32), }, }, ({ style }: StyledProps) => { const isCompact = style.layout === 'compact'; const divStyles: CssLikeObject = isCompact ? { bottom: '120%', left: 0, } : { [style.p]: '120%', left: 0, '@media (min-width: 768px)': { left: 'auto', right: 0, }, }; const spanStyles: CssLikeObject = isCompact ? { bottom: `-${px(6)}`, borderTop: `6px solid #000`, left: px(10), } : { [style.p === 'top' ? 'border-bottom' : 'border-top']: `6px solid #000`, [style.p]: '-6px', left: px(10), '@media (min-width: 768px)': { left: 'auto', right: px(10), }, }; return { '> button': { color: style.c, }, '> div': { ...divStyles, '> span': spanStyles, }, }; }, 'DevicesRSWP', ); const ListHeader = styled('div')({ p: { whiteSpace: 'nowrap', '&:nth-of-type(1)': { fontWeight: 'bold', marginBottom: px(8), }, '&:nth-of-type(2)': { alignItems: 'center', display: 'flex', span: { display: 'inline-block', marginLeft: px(4), }, }, }, }); function getDeviceIcon(type: string) { if (type.toLowerCase().includes('speaker')) { return ; } if (type.toLowerCase().includes('computer')) { return ; } return ; } export default function Devices(props: Props) { const { currentDeviceId, deviceId, devices = [], layout, locale, onClickDevice, open, playerPosition, styles: { color }, } = props; const [isOpen, setOpen] = useState(open); const handleClickSetDevice = (event: MouseEvent) => { const { dataset } = event.currentTarget; if (dataset.id) { onClickDevice(dataset.id); setOpen(false); } }; const handleClickToggleList = useCallback(() => { setOpen(s => !s); }, []); const { currentDevice, otherDevices } = devices.reduce( (acc, device) => { if (device.id === currentDeviceId) { acc.currentDevice = device; } else { acc.otherDevices.push(device); } return acc; }, { currentDevice: null, otherDevices: [] }, ); let icon = ; if (deviceId && currentDevice && currentDevice.id !== deviceId) { icon = getDeviceIcon(currentDevice.type); } return ( {!!devices.length && ( <> {isOpen && (
{currentDevice && (

{locale.currentDevice}

{getDeviceIcon(currentDevice.type)} {currentDevice.name}

)} {!!otherDevices.length && ( <>

{locale.otherDevices}

{otherDevices.map(device => ( ))} )}
)} )}
); } ================================================ FILE: src/components/ErrorMessage.tsx ================================================ import { px, styled } from '~/modules/styled'; import { ComponentsProps, StyledProps } from '~/types'; const Wrapper = styled('div')( { alignItems: 'center', display: 'flex', justifyContent: 'center', textAlign: 'center', width: '100%', }, ({ style }: StyledProps) => ({ backgroundColor: style.bgColor, borderTop: `1px solid ${style.errorColor}`, color: style.errorColor, height: px(style.h), }), 'ErrorRSWP', ); export default function ErrorMessage({ children, styles: { bgColor, errorColor, height }, }: ComponentsProps) { return ( {children} ); } ================================================ FILE: src/components/Info.tsx ================================================ import { memo, ReactNode, useEffect, useRef, useState } from 'react'; import { opacify } from 'colorizr'; import { getBgColor, getSpotifyLink, getSpotifyLinkTitle } from '~/modules/getters'; import { usePrevious } from '~/modules/hooks'; import { checkTracksStatus, removeTracks, saveTracks } from '~/modules/spotify'; import { CssLikeObject, px, styled } from '~/modules/styled'; import { Layout, Locale, SpotifyTrack, StyledProps, StylesOptions } from '~/types'; import Favorite from './icons/Favorite'; import FavoriteOutline from './icons/FavoriteOutline'; import SpotifyLogo from './SpotifyLogo'; interface Props { hideAttribution: boolean; hideCoverArt: boolean; isActive: boolean; layout: Layout; locale: Locale; onFavoriteStatusChange: (status: boolean) => any; showSaveIcon: boolean; styles: StylesOptions; token: string; track: SpotifyTrack; updateSavedStatus?: (fn: (status: boolean) => any) => any; } const imageSize = 64; const iconSize = 32; const Wrapper = styled('div')( { textAlign: 'left', '> a': { display: 'inline-flex', textDecoration: 'none', minHeight: px(64), minWidth: px(64), '&:hover': { textDecoration: 'underline', }, }, button: { alignItems: 'center', display: 'flex', fontSize: px(16), height: px(iconSize + 8), justifyContent: 'center', width: px(iconSize), }, }, ({ style }: StyledProps) => { const isCompactLayout = style.layout === 'compact'; const styles: CssLikeObject = {}; if (isCompactLayout) { styles.borderBottom = `1px solid ${opacify(style.c, 0.6)}`; styles['> a'] = { display: 'flex', margin: '0 auto', maxWidth: px(640), paddingBottom: '100%', position: 'relative', img: { display: 'block', bottom: 0, left: 0, maxWidth: '100%', position: 'absolute', right: 0, top: 0, }, }; } else { styles.alignItems = 'center'; styles.display = 'flex'; styles.minHeight = px(80); styles['@media (max-width: 767px)'] = { borderBottom: `1px solid ${opacify(style.c, 0.6)}`, paddingLeft: px(8), display: 'none', width: '100%', }; styles.img = { height: px(imageSize), width: px(imageSize), }; styles['&.rswp__active'] = { '@media (max-width: 767px)': { display: 'flex', }, }; } return { button: { color: style.c, '&.rswp__active': { color: style.activeColor, }, }, ...styles, }; }, 'InfoRSWP', ); const ContentWrapper = styled('div')( { display: 'flex', flexDirection: 'column', justifyContent: 'center', '> a': { fontSize: px(22), marginTop: px(4), }, }, ({ style }: StyledProps) => { const isCompactLayout = style.layout === 'compact'; const styles: CssLikeObject = {}; if (isCompactLayout) { styles.padding = px(8); styles.width = '100%'; } else { styles.minHeight = px(imageSize); if (!style.hideCoverArt) { styles.marginLeft = px(8); styles.width = `calc(100% - ${px(imageSize + 8)})`; } else { styles.width = '100%'; } } return styles; }, 'ContentWrapperRSWP', ); const Content = styled('div')( { display: 'flex', justifyContent: 'start', '[data-type="title-artist-wrapper"]': { overflow: 'hidden', div: { marginLeft: `-${px(8)}`, whiteSpace: 'nowrap', }, }, p: { fontSize: px(14), lineHeight: 1.3, paddingLeft: px(8), paddingRight: px(8), width: '100%', '&:nth-of-type(1)': { alignItems: 'center', display: 'inline-flex', }, '&:nth-of-type(2)': { fontSize: px(12), }, }, span: { display: 'inline-block', }, }, ({ style }: StyledProps) => { const maskImageColor = getBgColor(style.bgColor, style.trackNameColor); return { '[data-type="title-artist-wrapper"]': { color: style.trackNameColor, maxWidth: `calc(100% - ${px(style.showSaveIcon ? iconSize : 0)})`, div: { '-webkit-mask-image': `linear-gradient(90deg,transparent 0, ${maskImageColor} 6px, ${maskImageColor} calc(100% - 12px),transparent)`, }, }, p: { '&:nth-of-type(1)': { color: style.trackNameColor, a: { color: style.trackNameColor, }, }, '&:nth-of-type(2)': { color: style.trackArtistColor, a: { color: style.trackArtistColor, }, }, }, }; }, 'ContentRSWP', ); function Info(props: Props) { const { hideAttribution, hideCoverArt, isActive, layout, locale, onFavoriteStatusChange, showSaveIcon, styles: { activeColor, bgColor, color, height, trackArtistColor, trackNameColor }, token, track: { artists = [], id, image, name, uri }, updateSavedStatus, } = props; const [isSaved, setIsSaved] = useState(false); const isMounted = useRef(false); const previousId = usePrevious(id); const isCompactLayout = layout === 'compact'; const updateState = (state: boolean) => { if (!isMounted.current) { return; } setIsSaved(state); }; const setStatus = async () => { if (!isMounted.current) { return; } if (updateSavedStatus && id) { updateSavedStatus((newStatus: boolean) => { updateState(newStatus); }); } const status = await checkTracksStatus(token, id); const [isFavorite] = status || [false]; updateState(isFavorite); onFavoriteStatusChange(isSaved); }; useEffect(() => { isMounted.current = true; if (showSaveIcon && id) { setStatus(); } return () => { isMounted.current = false; }; // eslint-disable-next-line }, []); useEffect(() => { if (showSaveIcon && previousId !== id && id) { updateState(false); setStatus(); } }); const handleClickIcon = async () => { if (isSaved) { await removeTracks(token, id); updateState(false); } else { await saveTracks(token, id); updateState(true); } onFavoriteStatusChange(!isSaved); }; const title = getSpotifyLinkTitle(name, locale.title); let favorite; if (showSaveIcon && id) { favorite = ( ); } const content: Record = {}; const classes = []; if (isActive) { classes.push('rswp__active'); } if (isCompactLayout) { content.image = {name}; } if (!id) { return
; } return ( {!hideCoverArt && ( {name} )} {!!name && (

{name}

d.name).join(', ')}> {artists.map((artist, index) => { const artistTitle = getSpotifyLinkTitle(artist.name, locale.title); return ( {index ? ', ' : ''} {artist.name} ); })}

{favorite}
)} {!hideAttribution && ( )}
); } export default memo(Info); ================================================ FILE: src/components/Loader.tsx ================================================ import { keyframes, px, styled } from '~/modules/styled'; import { ComponentsProps, StyledProps } from '~/types'; const Wrapper = styled('div')( { alignItems: 'center', display: 'flex', jsutifyContent: 'center', position: 'relative', '> div': { borderRadius: '50%', borderStyle: 'solid', borderWidth: 0, boxSizing: 'border-box', height: 0, left: '50%', position: 'absolute', top: '50%', transform: 'translate(-50%, -50%)', width: 0, }, }, ({ style }: StyledProps) => { const pulse = keyframes!({ '0%': { height: 0, width: 0, }, '30%': { borderWidth: px(8), height: px(style.loaderSize), opacity: 1, width: px(style.loaderSize), }, '100%': { borderWidth: 0, height: px(style.loaderSize), opacity: 0, width: px(style.loaderSize), }, }); return { height: px(style.h), '> div': { animation: `${pulse} 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)`, borderColor: style.loaderColor, height: px(style.loaderSize), width: px(style.loaderSize), }, }; }, 'LoaderRSWP', ); export default function Loader({ styles: { height, loaderColor, loaderSize } }: ComponentsProps) { return (
); } ================================================ FILE: src/components/Player.tsx ================================================ import { forwardRef } from 'react'; import { px } from '~/modules/styled'; import { ComponentsProps } from '~/types'; const Player = forwardRef((props, ref) => { const { children, styles: { bgColor, height }, ...rest } = props; return (
{children}
); }); export default Player; ================================================ FILE: src/components/Slider.tsx ================================================ import { memo } from 'react'; import RangeSlider, { RangeSliderPosition } from '@gilbarbara/react-range-slider'; import { millisecondsToTime } from '~/modules/helpers'; import { px, styled } from '~/modules/styled'; import { StyledProps, StylesOptions } from '~/types'; interface Props { durationMs: number; isMagnified: boolean; onChangeRange: (position: number) => void; onToggleMagnify: () => void; position: number; progressMs: number; styles: StylesOptions; } const Wrapper = styled('div')( { alignItems: 'center', display: 'flex', fontSize: px(12), transition: 'height 0.3s', zIndex: 10, }, ({ style }: StyledProps) => ({ '[class^="rswp_"]': { color: style.c, lineHeight: 1, minWidth: px(32), }, '.rswp_progress': { marginRight: px(style.sliderHeight + 6), textAlign: 'right', }, '.rswp_duration': { marginLeft: px(style.sliderHeight + 6), textAlign: 'left', }, }), 'SliderRSWP', ); function Slider(props: Props) { const { durationMs, isMagnified, onChangeRange, onToggleMagnify, position, progressMs, styles } = props; const handleChangeRange = async ({ x }: RangeSliderPosition) => { onChangeRange(x); }; const handleSize = styles.sliderHeight + 6; return (
{millisecondsToTime(progressMs)}
{millisecondsToTime(durationMs)}
); } export default memo(Slider); ================================================ FILE: src/components/SpotifyLogo.tsx ================================================ import { textColor } from 'colorizr'; interface Props { bgColor: string; } export default function SpotifyLogo({ bgColor, ...rest }: Props) { return ( ); } ================================================ FILE: src/components/Volume.tsx ================================================ import { useCallback, useEffect, useRef, useState } from 'react'; import RangeSlider, { RangeSliderPosition } from '@gilbarbara/react-range-slider'; import { useMediaQuery, usePrevious } from '~/modules/hooks'; import { CssLikeObject, px, styled } from '~/modules/styled'; import { Layout, Locale, StyledProps, StylesOptions } from '~/types'; import ClickOutside from './ClickOutside'; import VolumeHigh from './icons/VolumeHigh'; import VolumeLow from './icons/VolumeLow'; import VolumeMid from './icons/VolumeMid'; import VolumeMute from './icons/VolumeMute'; interface Props { inlineVolume: boolean; layout: Layout; locale: Locale; playerPosition: string; setVolume: (volume: number) => any; styles: StylesOptions; volume: number; } const WrapperWithToggle = styled('div')( { display: 'none', 'pointer-events': 'all', position: 'relative', zIndex: 20, '> div': { alignItems: 'center', backgroundColor: '#000', borderRadius: px(4), color: '#fff', display: 'flex', filter: 'drop-shadow(1px 1px 6px rgba(0, 0, 0, 0.5))', flexDirection: 'column', left: '-4px', padding: px(16), position: 'absolute', '> span': { background: 'transparent', borderLeft: `6px solid transparent`, borderRight: `6px solid transparent`, content: '""', display: 'block', height: 0, position: 'absolute', width: 0, }, }, '> button': { alignItems: 'center', display: 'flex', fontSize: px(24), height: px(32), justifyContent: 'center', width: px(32), }, '@media (any-pointer: fine)': { display: 'block', }, }, ({ style }: StyledProps) => { const isCompact = style.layout === 'compact'; const spanStyles: CssLikeObject = isCompact ? { bottom: `-${px(6)}`, borderTop: `6px solid #000`, } : { [style.p === 'top' ? 'border-bottom' : 'border-top']: `6px solid #000`, [style.p]: '-6px', }; return { '> button': { color: style.c, }, '> div': { [isCompact ? 'bottom' : style.p]: '130%', '> span': spanStyles, }, }; }, 'VolumeRSWP', ); const WrapperInline = styled('div')( { display: 'none', padding: `0 ${px(8)}`, 'pointer-events': 'all', '> div': { display: 'flex', padding: `0 ${px(5)}`, width: px(100), }, '> span': { display: 'flex', fontSize: px(24), }, '@media (any-pointer: fine)': { alignItems: 'center', display: 'flex', }, }, ({ style }) => ({ color: style.c, }), 'VolumeInlineRSWP', ); export default function Volume(props: Props) { const { inlineVolume, layout, locale, playerPosition, setVolume, styles, volume } = props; const [isOpen, setIsOpen] = useState(false); const [volumeState, setVolumeState] = useState(volume); const timeoutRef = useRef(); const previousVolume = usePrevious(volume); const isMediumScreen = useMediaQuery('(min-width: 768px)'); const isInline = layout === 'responsive' && inlineVolume && isMediumScreen; useEffect(() => { if (previousVolume !== volume && volume !== volumeState) { setVolumeState(volume); } }, [previousVolume, volume, volumeState]); const handleClickToggleList = useCallback(() => { setIsOpen(s => !s); }, []); const handleChangeSlider = ({ x, y }: RangeSliderPosition) => { const value = isInline ? x : y; const currentvolume = Math.round(value) / 100; clearTimeout(timeoutRef.current); timeoutRef.current = window.setTimeout(() => { setVolume(currentvolume); }, 250); setVolumeState(currentvolume); }; const handleAfterEnd = () => { setTimeout(() => { setIsOpen(false); }, 100); }; let icon = ; if (volume === 0) { icon = ; } else if (volume <= 0.4) { icon = ; } else if (volume <= 0.7) { icon = ; } if (isInline) { return ( {icon}
); } return ( {isOpen && (
)}
); } ================================================ FILE: src/components/Wrapper.tsx ================================================ import { memo } from 'react'; import { CssLikeObject, px, styled } from '~/modules/styled'; import { ComponentsProps, StyledProps } from '~/types'; const StyledWrapper = styled('div')( { alignItems: 'center', display: 'flex', flexDirection: 'column', flexWrap: 'wrap', justifyContent: 'center', position: 'relative', '> *': { width: '100%', }, }, ({ style }: StyledProps) => { let styles: CssLikeObject = {}; if (style.layout === 'responsive') { styles = { '> *': { '@media (min-width: 768px)': { width: '33.3333%', }, }, '@media (min-width: 768px)': { flexDirection: 'row', padding: `0 ${px(8)}`, }, }; } return { minHeight: px(style.h), ...styles, }; }, 'WrapperRSWP', ); function Wrapper(props: ComponentsProps) { const { children, layout, styles } = props; return ( {children} ); } export default memo(Wrapper); ================================================ FILE: src/components/icons/Devices.tsx ================================================ export default function DevicesIcon(props: any) { return ( ); } ================================================ FILE: src/components/icons/DevicesComputer.tsx ================================================ export default function DevicesComputerIcon(props: any) { return ( ); } ================================================ FILE: src/components/icons/DevicesMobile.tsx ================================================ export default function DevicesMobileIcon(props: any) { return ( ); } ================================================ FILE: src/components/icons/DevicesSpeaker.tsx ================================================ export default function DevicesSpeakerIcon(props: any) { return ( ); } ================================================ FILE: src/components/icons/Favorite.tsx ================================================ export default function Favorite(props: any) { return ( ); } ================================================ FILE: src/components/icons/FavoriteOutline.tsx ================================================ export default function FavoriteOutline(props: any) { return ( ); } ================================================ FILE: src/components/icons/Next.tsx ================================================ export default function Next(props: any) { return ( ); } ================================================ FILE: src/components/icons/Pause.tsx ================================================ export default function Pause(props: any) { return ( ); } ================================================ FILE: src/components/icons/Play.tsx ================================================ export default function Play(props: any) { return ( ); } ================================================ FILE: src/components/icons/Previous.tsx ================================================ export default function Previous(props: any) { return ( ); } ================================================ FILE: src/components/icons/VolumeHigh.tsx ================================================ export default function VolumeHigh(props: any) { return ( ); } ================================================ FILE: src/components/icons/VolumeLow.tsx ================================================ export default function VolumeLow(props: any) { return ( ); } ================================================ FILE: src/components/icons/VolumeMid.tsx ================================================ export default function VolumeHigh(props: any) { return ( ); } ================================================ FILE: src/components/icons/VolumeMute.tsx ================================================ export default function VolumeMute(props: any) { return ( ); } ================================================ FILE: src/constants.ts ================================================ export const ERROR_TYPE = { ACCOUNT: 'account', AUTHENTICATION: 'authentication', INITIALIZATION: 'initialization', PLAYBACK: 'playback', PLAYER: 'player', } as const; export const SPOTIFY_CONTENT_TYPE = { ALBUM: 'album', ARTIST: 'artist', PLAYLIST: 'playlist', SHOW: 'show', TRACK: 'track', }; export const STATUS = { ERROR: 'ERROR', IDLE: 'IDLE', INITIALIZING: 'INITIALIZING', READY: 'READY', RUNNING: 'RUNNING', UNSUPPORTED: 'UNSUPPORTED', } as const; export const TRANSPARENT_COLOR = 'rgba(0, 0, 0, 0)'; export const TYPE = { DEVICE: 'device_update', FAVORITE: 'favorite_update', PLAYER: 'player_update', PRELOAD: 'preload_update', PROGRESS: 'progress_update', STATUS: 'status_update', TRACK: 'track_update', } as const; ================================================ FILE: src/index.tsx ================================================ /* eslint-disable camelcase */ import { createRef, PureComponent, ReactNode } from 'react'; import isEqual from '@gilbarbara/deep-equal'; import memoize from 'memoize-one'; import { ERROR_TYPE, STATUS, TYPE } from '~/constants'; import { getItemImage, getLocale, getMergedStyles, getPreloadData, getRepeatState, getSpotifyURIType, getTrackInfo, } from '~/modules/getters'; import { loadSpotifyPlayer, parseIds, parseVolume, round, validateURI } from '~/modules/helpers'; import { getDevices, getPlaybackState, next, pause, play, previous, seek, setDevice, setVolume, } from '~/modules/spotify'; import { put } from '~/modules/styled'; import Actions from '~/components/Actions'; import Controls from '~/components/Controls'; import Devices from '~/components/Devices'; import ErrorMessage from '~/components/ErrorMessage'; import Info from '~/components/Info'; import Loader from '~/components/Loader'; import Player from '~/components/Player'; import Volume from '~/components/Volume'; import Wrapper from '~/components/Wrapper'; import { CallbackState, ErrorType, Locale, PlayOptions, Props, SpotifyArtist, SpotifyDevice, SpotifyPlayerCallback, State, Status, StylesOptions, } from './types'; put('.PlayerRSWP', { boxSizing: 'border-box', fontSize: 'inherit', width: '100%', '*': { boxSizing: 'border-box', }, p: { margin: 0, }, }); put('.ButtonRSWP', { appearance: 'none', background: 'transparent', border: 0, borderRadius: 0, color: 'inherit', cursor: 'pointer', display: 'inline-flex', lineHeight: 1, padding: 0, ':focus': { outlineColor: '#000', outlineOffset: 3, }, }); export type SpotifyPlayer = Spotify.Player; export { ERROR_TYPE, STATUS, TYPE } from './constants'; class SpotifyWebPlayer extends PureComponent { private isMounted = false; private emptyTrack = { artists: [] as SpotifyArtist[], durationMs: 0, id: '', image: '', name: '', uri: '', }; private locale: Locale; private player?: Spotify.Player; private playerProgressInterval?: number; private playerSyncInterval?: number; private ref = createRef(); private renderInlineActions = false; private resizeTimeout?: number; private seekUpdateInterval = 100; private styles: StylesOptions; private syncTimeout?: number; private getPlayOptions = memoize((ids: string[]): PlayOptions => { const playOptions: PlayOptions = { context_uri: undefined, uris: undefined, }; if (ids) { if (!ids.every(d => validateURI(d))) { return playOptions; } if (ids.some(d => getSpotifyURIType(d) === 'track')) { if (!ids.every(d => getSpotifyURIType(d) === 'track')) { // eslint-disable-next-line no-console console.warn("You can't mix tracks URIs with other types"); } playOptions.uris = ids.filter(d => validateURI(d) && getSpotifyURIType(d) === 'track'); } else { if (ids.length > 1) { // eslint-disable-next-line no-console console.warn("Albums, Artists, Playlists and Podcasts can't have multiple URIs"); } // eslint-disable-next-line prefer-destructuring playOptions.context_uri = ids[0]; } } return playOptions; }); constructor(props: Props) { super(props); this.state = { currentDeviceId: '', currentURI: '', deviceId: '', devices: [], error: '', errorType: null, isActive: false, isInitializing: false, isMagnified: false, isPlaying: false, isSaved: false, isUnsupported: false, needsUpdate: false, nextTracks: [], playerPosition: 'bottom', position: 0, previousTracks: [], progressMs: 0, repeat: 'off', shuffle: false, status: STATUS.IDLE, track: this.emptyTrack, volume: parseVolume(props.initialVolume) || 1, }; this.locale = getLocale(props.locale); this.styles = getMergedStyles(props.styles); } static defaultProps = { autoPlay: false, initialVolume: 1, magnifySliderOnHover: false, name: 'Spotify Web Player', persistDeviceSelection: false, showSaveIcon: false, syncExternalDeviceInterval: 5, syncExternalDevice: false, }; public async componentDidMount() { this.isMounted = true; const { top = 0 } = this.ref.current?.getBoundingClientRect() ?? {}; this.updateState({ playerPosition: top > window.innerHeight / 2 ? 'bottom' : 'top', status: STATUS.INITIALIZING, }); if (!window.onSpotifyWebPlaybackSDKReady) { window.onSpotifyWebPlaybackSDKReady = this.initializePlayer; } else { this.initializePlayer(); } await loadSpotifyPlayer(); window.addEventListener('resize', this.handleResize); this.handleResize(); } public async componentDidUpdate(previousProps: Props, previousState: State) { const { currentDeviceId, deviceId, isInitializing, isPlaying, repeat, shuffle, status, track } = this.state; const { autoPlay, layout, locale, offset, play: playProp, showSaveIcon, styles, syncExternalDevice, uris, } = this.props; const isReady = previousState.status !== STATUS.READY && status === STATUS.READY; const playOptions = this.getPlayOptions(parseIds(uris)); const canPlay = !!currentDeviceId && !!(playOptions.context_uri ?? playOptions.uris); const shouldPlay = isReady && (autoPlay || playProp); if (canPlay && shouldPlay) { await this.togglePlay(true); if (!isPlaying) { this.updateState({ isPlaying: true }); } if (this.isExternalPlayer) { this.syncTimeout = window.setTimeout(() => { this.syncDevice(); }, 600); } } else if (!isEqual(previousProps.uris, uris)) { if (isPlaying || playProp) { await this.togglePlay(true); } else { this.updateState({ needsUpdate: true }); } } else if (previousProps.play !== playProp && playProp !== isPlaying) { await this.togglePlay(!track.id); } if (previousState.status !== status) { this.handleCallback({ ...this.state, type: TYPE.STATUS, }); } if (previousState.currentDeviceId !== currentDeviceId && currentDeviceId) { if (!isReady) { this.handleCallback({ ...this.state, type: TYPE.DEVICE, }); } await this.toggleSyncInterval(this.isExternalPlayer); await this.updateSeekBar(); } if (track.id && previousState.track.id !== track.id) { this.handleCallback({ ...this.state, type: TYPE.TRACK, }); if (showSaveIcon) { this.updateState({ isSaved: false }); } } if (previousState.isPlaying !== isPlaying) { this.toggleProgressBar(); await this.toggleSyncInterval(this.isExternalPlayer); this.handleCallback({ ...this.state, type: TYPE.PLAYER, }); } if (previousState.repeat !== repeat || previousState.shuffle !== shuffle) { this.handleCallback({ ...this.state, type: TYPE.PLAYER, }); } if (previousProps.offset !== offset) { await this.toggleOffset(); } if (previousState.isInitializing && !isInitializing) { if (syncExternalDevice && !uris) { const playerState = await getPlaybackState(this.token); if (playerState?.is_playing && playerState.device.id !== deviceId) { this.setExternalDevice(playerState.device.id ?? ''); } } } if (previousProps.layout !== layout) { this.handleResize(); } if (!isEqual(previousProps.locale, locale)) { this.locale = getLocale(locale); } if (!isEqual(previousProps.styles, styles)) { this.styles = getMergedStyles(styles); } } public async componentWillUnmount() { this.isMounted = false; if (this.player) { this.player.disconnect(); } clearInterval(this.playerSyncInterval); clearInterval(this.playerProgressInterval); clearTimeout(this.syncTimeout); window.removeEventListener('resize', this.handleResize); } private handleCallback(state: CallbackState): void { const { callback } = this.props; if (callback) { callback(state); } } private handleChangeRange = async (position: number) => { const { track } = this.state; const { callback } = this.props; let progress = 0; try { const percentage = position / 100; let stateChanges = {}; if (this.isExternalPlayer) { progress = Math.round(track.durationMs * percentage); await seek(this.token, progress); stateChanges = { position, progressMs: progress, }; } else if (this.player) { const state = await this.player.getCurrentState(); if (state) { progress = Math.round(state.track_window.current_track.duration_ms * percentage); await this.player.seek(progress); stateChanges = { position, progressMs: progress, }; } else { stateChanges = { position: 0 }; } } this.updateState(stateChanges); if (callback) { callback({ ...this.state, ...stateChanges, type: TYPE.PROGRESS, }); } } catch (error) { // eslint-disable-next-line no-console console.error(error); } }; private handleClickTogglePlay = async () => { const { isActive } = this.state; try { await this.togglePlay(!this.isExternalPlayer && !isActive); } catch (error) { // eslint-disable-next-line no-console console.error(error); } }; private handleClickPrevious = async () => { try { if (this.isExternalPlayer) { await previous(this.token); this.syncTimeout = window.setTimeout(() => { this.syncDevice(); }, 300); } else if (this.player) { await this.player.previousTrack(); } } catch (error) { // eslint-disable-next-line no-console console.error(error); } }; private handleClickNext = async () => { try { if (this.isExternalPlayer) { await next(this.token); this.syncTimeout = window.setTimeout(() => { this.syncDevice(); }, 300); } else if (this.player) { await this.player.nextTrack(); } } catch (error) { // eslint-disable-next-line no-console console.error(error); } }; private handleClickDevice = async (deviceId: string) => { const { isUnsupported } = this.state; const { autoPlay, persistDeviceSelection } = this.props; this.updateState({ currentDeviceId: deviceId }); try { await setDevice(this.token, deviceId); if (persistDeviceSelection) { sessionStorage.setItem('rswpDeviceId', deviceId); } if (isUnsupported) { await this.syncDevice(); const playerState = await getPlaybackState(this.token); if (playerState && !playerState.is_playing && autoPlay) { await this.togglePlay(true); } } } catch (error) { // eslint-disable-next-line no-console console.error(error); } }; private handleFavoriteStatusChange = (status: boolean) => { const { isSaved } = this.state; this.updateState({ isSaved: status }); if (isSaved !== status) { this.handleCallback({ ...this.state, isSaved: status, type: TYPE.FAVORITE, }); } }; private handlePlayerErrors = async (type: ErrorType, message: string) => { const { status } = this.state; const isPlaybackError = type === ERROR_TYPE.PLAYBACK; const isInitializationError = type === ERROR_TYPE.INITIALIZATION; let nextStatus = status; let devices: SpotifyDevice[] = []; if (this.player && !isPlaybackError) { this.player.disconnect(); this.player = undefined; } if (isInitializationError) { nextStatus = STATUS.UNSUPPORTED; ({ devices = [] } = await getDevices(this.token)); } else if (!isPlaybackError) { nextStatus = STATUS.ERROR; } this.updateState({ devices, error: message, errorType: type, isInitializing: false, isUnsupported: isInitializationError, status: nextStatus, }); }; private handlePlayerStateChanges = async (state: Spotify.PlaybackState) => { const { currentURI } = this.state; try { if (state) { const { paused, position, repeat_mode, shuffle, track_window: { current_track, next_tracks, previous_tracks }, } = state; const isPlaying = !paused; const volume = (await this.player?.getVolume()) ?? 100; let trackState = {}; if ((!currentURI || currentURI !== current_track.uri) && current_track) { trackState = { currentURI: current_track.uri, nextTracks: next_tracks.map(getTrackInfo), position: 0, previousTracks: previous_tracks.map(getTrackInfo), track: getTrackInfo(current_track), }; } this.updateState({ error: '', errorType: null, isActive: true, isPlaying, progressMs: position, repeat: getRepeatState(repeat_mode), shuffle, volume: round(volume), ...trackState, }); } else if (this.isExternalPlayer) { await this.syncDevice(); } else { this.updateState({ isActive: false, isPlaying: false, nextTracks: [], position: 0, previousTracks: [], track: { artists: [], durationMs: 0, id: '', image: '', name: '', uri: '', }, }); } } catch (error) { // eslint-disable-next-line no-console console.error(error); } }; private handlePlayerStatus = async ({ device_id }: Spotify.WebPlaybackInstance) => { const { currentDeviceId, devices } = await this.initializeDevices(device_id); this.updateState({ currentDeviceId, deviceId: device_id, devices, isInitializing: false, status: device_id ? STATUS.READY : STATUS.IDLE, }); if (device_id) { await this.preload(); } }; private handleResize = () => { const { layout = 'responsive' } = this.props; clearTimeout(this.resizeTimeout); this.resizeTimeout = window.setTimeout(() => { this.renderInlineActions = window.innerWidth >= 768 && layout === 'responsive'; this.forceUpdate(); }, 100); }; private handleToggleMagnify = () => { const { magnifySliderOnHover } = this.props; if (magnifySliderOnHover) { this.updateState(previousState => { return { isMagnified: !previousState.isMagnified }; }); } }; private get token(): string { const { token } = this.props; return token; } private async initializeDevices(id: string) { const { persistDeviceSelection } = this.props; const { devices } = await getDevices(this.token); let currentDeviceId = id; if (persistDeviceSelection) { const savedDeviceId = sessionStorage.getItem('rswpDeviceId'); if (!savedDeviceId || !devices.some((d: SpotifyDevice) => d.id === savedDeviceId)) { sessionStorage.setItem('rswpDeviceId', currentDeviceId); } else { currentDeviceId = savedDeviceId; } } return { currentDeviceId, devices }; } private initializePlayer = () => { const { volume } = this.state; const { getOAuthToken = (callback: SpotifyPlayerCallback) => { callback(this.token); }, getPlayer, name = 'Spotify Web Player', } = this.props; if (!window.Spotify) { return; } this.updateState({ error: '', errorType: null, isInitializing: true, }); this.player = new window.Spotify.Player({ getOAuthToken, name, volume, }); this.player.addListener('ready', this.handlePlayerStatus); this.player.addListener('not_ready', this.handlePlayerStatus); this.player.addListener('player_state_changed', this.handlePlayerStateChanges); this.player.addListener('initialization_error', error => this.handlePlayerErrors(ERROR_TYPE.INITIALIZATION, error.message), ); this.player.addListener('authentication_error', error => this.handlePlayerErrors(ERROR_TYPE.AUTHENTICATION, error.message), ); this.player.addListener('account_error', error => this.handlePlayerErrors(ERROR_TYPE.ACCOUNT, error.message), ); this.player.addListener('playback_error', error => this.handlePlayerErrors(ERROR_TYPE.PLAYBACK, error.message), ); this.player.addListener('autoplay_failed', async () => { // eslint-disable-next-line no-console console.log('Autoplay is not allowed by the browser autoplay rules'); }); this.player.connect(); if (getPlayer) { getPlayer(this.player); } }; private get isExternalPlayer(): boolean { const { currentDeviceId, deviceId, status } = this.state; return (currentDeviceId && currentDeviceId !== deviceId) || status === STATUS.UNSUPPORTED; } private preload = async () => { const { offset = 0, preloadData, uris } = this.props; if (!preloadData) { return; } const track = await getPreloadData(this.token, uris, offset); if (track) { this.updateState({ track }, () => { this.handleCallback({ ...this.state, type: TYPE.PRELOAD, }); }); } }; private setExternalDevice = (id: string) => { this.updateState({ currentDeviceId: id, isPlaying: true }); }; private setVolume = async (volume: number) => { if (this.isExternalPlayer) { await setVolume(this.token, Math.round(volume * 100)); await this.syncDevice(); } else if (this.player) { await this.player.setVolume(volume); } this.updateState({ volume }); }; private syncDevice = async () => { if (!this.isMounted) { return; } const { deviceId } = this.state; try { const playerState = await getPlaybackState(this.token); let track = this.emptyTrack; if (!playerState) { throw new Error('No player'); } if (playerState.item) { track = { artists: 'artists' in playerState.item ? playerState.item.artists : [], durationMs: playerState.item.duration_ms, id: playerState.item.id, image: 'album' in playerState.item ? getItemImage(playerState.item.album) : '', name: playerState.item.name, uri: playerState.item.uri, }; } this.updateState({ error: '', errorType: null, isActive: true, isPlaying: playerState.is_playing, nextTracks: [], previousTracks: [], progressMs: playerState.item ? playerState.progress_ms ?? 0 : 0, status: STATUS.READY, track, volume: parseVolume(playerState.device.volume_percent), }); } catch (error: any) { const state = { isActive: false, isPlaying: false, position: 0, track: this.emptyTrack, }; if (deviceId) { this.updateState({ currentDeviceId: deviceId, ...state, }); return; } this.updateState({ error: error.message, errorType: ERROR_TYPE.PLAYER, status: STATUS.ERROR, ...state, }); } }; private async toggleSyncInterval(shouldSync: boolean) { const { syncExternalDeviceInterval } = this.props; try { if (this.isExternalPlayer && shouldSync && !this.playerSyncInterval) { await this.syncDevice(); clearInterval(this.playerSyncInterval); this.playerSyncInterval = window.setInterval( this.syncDevice, syncExternalDeviceInterval! * 1000, ); } if ((!shouldSync || !this.isExternalPlayer) && this.playerSyncInterval) { clearInterval(this.playerSyncInterval); this.playerSyncInterval = undefined; } } catch (error) { // eslint-disable-next-line no-console console.error(error); } } private toggleProgressBar() { const { isPlaying } = this.state; if (isPlaying) { if (!this.playerProgressInterval) { this.playerProgressInterval = window.setInterval( this.updateSeekBar, this.seekUpdateInterval, ); } } else if (this.playerProgressInterval) { clearInterval(this.playerProgressInterval); this.playerProgressInterval = undefined; } } private toggleOffset = async () => { const { currentDeviceId } = this.state; const { offset, uris } = this.props; const playOptions = this.getPlayOptions(parseIds(uris)); if (typeof offset === 'number') { await play(this.token, { deviceId: currentDeviceId, offset, ...playOptions }); } }; private togglePlay = async (force = false) => { const { currentDeviceId, isPlaying, needsUpdate } = this.state; const { offset, uris } = this.props; const shouldInitialize = force || needsUpdate; const playOptions = this.getPlayOptions(parseIds(uris)); try { if (this.isExternalPlayer) { if (!isPlaying) { await play(this.token, { deviceId: currentDeviceId, offset, ...(shouldInitialize ? playOptions : undefined), }); } else { await pause(this.token); this.updateState({ isPlaying: false }); } this.syncTimeout = window.setTimeout(() => { this.syncDevice(); }, 300); } else if (this.player) { await this.player.activateElement(); const playerState = await this.player.getCurrentState(); const shouldPlay = !playerState && !!(playOptions.context_uri ?? playOptions.uris); if (shouldPlay || shouldInitialize) { await play(this.token, { deviceId: currentDeviceId, offset, ...(shouldInitialize ? playOptions : undefined), }); await this.player.togglePlay(); } else { await this.player.togglePlay(); } } if (needsUpdate) { this.updateState({ needsUpdate: false }); } } catch (error) { // eslint-disable-next-line no-console console.error(error); } }; private updateSeekBar = async () => { if (!this.isMounted) { return; } const { progressMs, track } = this.state; try { if (this.isExternalPlayer) { let position = progressMs / track.durationMs; position = Number(((Number.isFinite(position) ? position : 0) * 100).toFixed(1)); this.updateState({ position, progressMs: progressMs + this.seekUpdateInterval, }); } else if (this.player) { const state = await this.player.getCurrentState(); if (state) { const progress = state.position; const position = Number( ((progress / state.track_window.current_track.duration_ms) * 100).toFixed(1), ); this.updateState({ position, progressMs: progress + this.seekUpdateInterval, }); } } } catch (error) { // eslint-disable-next-line no-console console.error(error); } }; private updateState: typeof this.setState = (state, callback) => { if (!this.isMounted) { return; } this.setState(state, callback); }; public render() { const { currentDeviceId, deviceId, devices, error, isActive, isMagnified, isPlaying, isUnsupported, nextTracks, playerPosition, position, progressMs, status, track, volume, } = this.state; const { components, hideAttribution = false, hideCoverArt = false, inlineVolume = true, layout = 'responsive', showSaveIcon, updateSavedStatus, } = this.props; const isReady = ([STATUS.READY, STATUS.UNSUPPORTED] as Status[]).includes(status); const output: Record = { main: , }; if (isReady) { if (!output.info) { output.info = ( ); } output.devices = ( ); output.volume = currentDeviceId ? ( ) : null; if (this.renderInlineActions) { output.actions = ( {output.devices} {output.volume} ); } output.controls = ( ); output.main = ( {output.info} {output.controls} {output.actions} ); } else if (output.info) { output.main = output.info; } if (status === STATUS.ERROR) { output.main = {error}; } return ( {output.main} ); } } export * as spotifyApi from './modules/spotify'; export * from './types'; export default SpotifyWebPlayer; ================================================ FILE: src/modules/getters.ts ================================================ /* eslint-disable camelcase */ import { SPOTIFY_CONTENT_TYPE, TRANSPARENT_COLOR } from '~/constants'; import { parseIds, validateURI } from '~/modules/helpers'; import { getAlbumTracks, getArtistTopTracks, getPlaylistTracks, getShow, getShowEpisodes, getTrack, } from '~/modules/spotify'; import { IDs, Locale, RepeatState, SpotifyTrack, StylesOptions, StylesProps } from '~/types'; export function getBgColor(bgColor: string, fallbackColor?: string): string { if (fallbackColor) { return bgColor === TRANSPARENT_COLOR ? fallbackColor : bgColor; } return bgColor === 'transparent' ? TRANSPARENT_COLOR : bgColor; } export function getItemImage(item: { images: Spotify.Image[] }): string { const maxWidth = Math.max(...item.images.map(d => d.width ?? 0)); return item.images.find(d => d.width === maxWidth)?.url ?? ''; } export function getLocale(locale?: Partial): Locale { return { currentDevice: 'Current device', devices: 'Devices', next: 'Next', otherDevices: 'Select other device', pause: 'Pause', play: 'Play', previous: 'Previous', removeTrack: 'Remove from your favorites', saveTrack: 'Save to your favorites', title: '{name} on SPOTIFY', volume: 'Volume', ...locale, }; } export function getMergedStyles(styles?: StylesProps): StylesOptions { const mergedStyles = { activeColor: '#1cb954', bgColor: '#fff', color: '#333', errorColor: '#ff0026', height: 80, loaderColor: '#ccc', loaderSize: 32, sliderColor: '#666', sliderHandleBorderRadius: '50%', sliderHandleColor: '#000', sliderHeight: 4, sliderTrackBorderRadius: 4, sliderTrackColor: '#ccc', trackArtistColor: '#666', trackNameColor: '#333', ...styles, }; mergedStyles.bgColor = getBgColor(mergedStyles.bgColor); return mergedStyles; } export async function getPreloadData( token: string, uris: IDs, offset: number, ): Promise { const parsedURIs = parseIds(uris); const uri = parsedURIs[offset]; if (!validateURI(uri)) { if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console console.error('PreloadData: Invalid URI', parsedURIs[offset]); } return null; } const [, type, id] = uri.split(':'); try { switch (type) { case SPOTIFY_CONTENT_TYPE.ALBUM: { const { items } = await getAlbumTracks(token, id); const track = await getTrack(token, items[offset].id); return getTrackInfo(track); } case SPOTIFY_CONTENT_TYPE.ARTIST: { const { tracks } = await getArtistTopTracks(token, id); return getTrackInfo(tracks[offset]); } case SPOTIFY_CONTENT_TYPE.PLAYLIST: { const { items } = await getPlaylistTracks(token, id); if (items[offset]?.track) { return getTrackInfo(items[offset]?.track); } return null; } case SPOTIFY_CONTENT_TYPE.SHOW: { const show = await getShow(token, id); const { items } = await getShowEpisodes( token, id, show.total_episodes ? show.total_episodes - 1 : 0, ); const episode = items?.[0] ?? { duration_ms: 0, id: show.id, images: show.images, name: show.name, uri: show.uri, }; return { artists: [{ name: show.name, uri: show.uri }], durationMs: episode.duration_ms, id: episode.id, image: getItemImage(episode), name: episode.name, uri: episode.uri, }; } default: { const track = await getTrack(token, id); return getTrackInfo(track); } } } catch (error) { // eslint-disable-next-line no-console console.error('PreloadData:', error); return null; } } export function getRepeatState(mode: number): RepeatState { switch (mode) { case 1: return 'context'; case 2: return 'track'; case 0: default: return 'off'; } } export function getSpotifyLink(uri: string): string { const [, type = '', id = ''] = uri.split(':'); return `https://open.spotify.com/${type}/${id}`; } export function getSpotifyLinkTitle(name: string, locale: string): string { return locale.replace('{name}', name); } export function getSpotifyURIType(uri: string): string { const [, type = ''] = uri.split(':'); return type; } export function getTrackInfo(track: Spotify.Track | SpotifyApi.TrackObjectFull): SpotifyTrack { const { album, artists, duration_ms, id, name, uri } = track; return { artists, durationMs: duration_ms, id: id ?? '', image: getItemImage(album), name, uri, }; } ================================================ FILE: src/modules/helpers.ts ================================================ import { SPOTIFY_CONTENT_TYPE } from '~/constants'; import { IDs } from '~/types'; export function isNumber(value: unknown): value is number { return typeof value === 'number'; } export function loadSpotifyPlayer(): Promise { return new Promise((resolve, reject) => { const scriptTag = document.getElementById('spotify-player'); if (!scriptTag) { const script = document.createElement('script'); script.id = 'spotify-player'; script.type = 'text/javascript'; script.async = false; script.defer = true; script.src = 'https://sdk.scdn.co/spotify-player.js'; script.onload = () => resolve(); script.onerror = (error: any) => reject(new Error(`loadScript: ${error.message}`)); document.head.appendChild(script); } else { resolve(); } }); } export function millisecondsToTime(input: number) { const seconds = Math.floor((input / 1000) % 60); const minutes = Math.floor((input / (1000 * 60)) % 60); const hours = Math.floor((input / (1000 * 60 * 60)) % 24); const parts: string[] = []; if (hours > 0) { parts.push( `${hours}`.padStart(2, '0'), `${minutes}`.padStart(2, '0'), `${seconds}`.padStart(2, '0'), ); } else { parts.push(`${minutes}`, `${seconds}`.padStart(2, '0')); } return parts.join(':'); } export function parseIds(ids: IDs): string[] { if (!ids) { return []; } return Array.isArray(ids) ? ids : [ids]; } export function parseVolume(value?: unknown): number { if (!isNumber(value)) { return 1; } if (value > 1) { return value / 100; } return value; } /** * Round decimal numbers */ export function round(number: number, digits = 2) { const factor = 10 ** digits; return Math.round(number * factor) / factor; } export function validateURI(input: string): boolean { if (input && input.indexOf(':') > -1) { const [key, type, id] = input.split(':'); if ( key === 'spotify' && Object.values(SPOTIFY_CONTENT_TYPE).includes(type) && id.length === 22 ) { return true; } } return false; } ================================================ FILE: src/modules/hooks.ts ================================================ import { useEffect, useRef, useState } from 'react'; export function useMediaQuery(input: string): boolean { const getMatches = (query: string): boolean => { return window.matchMedia(query).matches; }; const [matches, setMatches] = useState(getMatches(input)); function handleChange() { setMatches(getMatches(input)); } useEffect(() => { const matchMedia = window.matchMedia(input); // Triggered at the first client-side load and if query changes handleChange(); try { matchMedia.addEventListener('change', handleChange); /* c8 ignore next 4 */ } catch { // Safari isn't supporting matchMedia.addEventListener matchMedia.addListener(handleChange); } return () => { try { matchMedia.removeEventListener('change', handleChange); /* c8 ignore next 4 */ } catch { // Safari isn't supporting matchMedia.removeEventListener matchMedia.removeListener(handleChange); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [input]); return matches; } export function usePrevious(value: T): T { const ref: any = useRef(); useEffect(() => { ref.current = value; }, [value]); return ref.current; } ================================================ FILE: src/modules/spotify.ts ================================================ /* eslint-disable camelcase */ import { parseIds } from '~/modules/helpers'; import { IDs, RepeatState, SpotifyPlayOptions } from '~/types'; export async function checkTracksStatus(token: string, tracks: IDs): Promise { return fetch(`https://api.spotify.com/v1/me/tracks/contains?ids=${parseIds(tracks)}`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'GET', }).then(d => d.json()); } export async function getAlbumTracks( token: string, id: string, ): Promise { return fetch(`https://api.spotify.com/v1/albums/${id}/tracks`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'GET', }).then(d => d.json()); } export async function getArtistTopTracks( token: string, id: string, ): Promise { return fetch(`https://api.spotify.com/v1/artists/${id}/top-tracks`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'GET', }).then(d => d.json()); } export async function getDevices(token: string): Promise { return fetch(`https://api.spotify.com/v1/me/player/devices`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'GET', }).then(d => d.json()); } export async function getPlaybackState( token: string, ): Promise { return fetch(`https://api.spotify.com/v1/me/player`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'GET', }).then(d => { if (d.status === 204) { return null; } return d.json(); }); } export async function getPlaylistTracks( token: string, id: string, ): Promise { return fetch(`https://api.spotify.com/v1/playlists/${id}/tracks`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'GET', }).then(d => d.json()); } export async function getQueue(token: string): Promise { return fetch(`https://api.spotify.com/v1/me/player/queue`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'GET', }).then(d => d.json()); } export async function getShow(token: string, id: string): Promise { return fetch(`https://api.spotify.com/v1/shows/${id}`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'GET', }).then(d => d.json()); } export async function getShowEpisodes( token: string, id: string, offset = 0, ): Promise { return fetch(`https://api.spotify.com/v1/shows/${id}/episodes?offset=${offset}`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'GET', }).then(d => d.json()); } export async function getTrack(token: string, id: string): Promise { return fetch(`https://api.spotify.com/v1/tracks/${id}`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'GET', }).then(d => d.json()); } export async function next(token: string, deviceId?: string): Promise { let query = ''; if (deviceId) { query += `?device_id=${deviceId}`; } await fetch(`https://api.spotify.com/v1/me/player/next${query}`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'POST', }); } export async function pause(token: string, deviceId?: string): Promise { let query = ''; if (deviceId) { query += `?device_id=${deviceId}`; } await fetch(`https://api.spotify.com/v1/me/player/pause${query}`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'PUT', }); } export async function play( token: string, { context_uri, deviceId, offset = 0, uris }: SpotifyPlayOptions, ): Promise { let body; if (context_uri) { const isArtist = context_uri.indexOf('artist') >= 0; let position; if (!isArtist) { position = { position: offset }; } body = JSON.stringify({ context_uri, offset: position }); } else if (Array.isArray(uris) && uris.length) { body = JSON.stringify({ uris, offset: { position: offset } }); } await fetch(`https://api.spotify.com/v1/me/player/play?device_id=${deviceId}`, { body, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'PUT', }); } export async function previous(token: string, deviceId?: string): Promise { let query = ''; if (deviceId) { query += `?device_id=${deviceId}`; } await fetch(`https://api.spotify.com/v1/me/player/previous${query}`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'POST', }); } export async function removeTracks(token: string, tracks: IDs): Promise { await fetch(`https://api.spotify.com/v1/me/tracks`, { body: JSON.stringify({ ids: parseIds(tracks) }), headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'DELETE', }); } export async function repeat(token: string, state: RepeatState, deviceId?: string): Promise { let query = `?state=${state}`; if (deviceId) { query += `&device_id=${deviceId}`; } await fetch(`https://api.spotify.com/v1/me/player/repeat${query}`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'PUT', }); } export async function saveTracks(token: string, tracks: IDs): Promise { await fetch(`https://api.spotify.com/v1/me/tracks`, { body: JSON.stringify({ ids: parseIds(tracks) }), headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'PUT', }); } export async function seek(token: string, position: number, deviceId?: string): Promise { let query = `?position_ms=${position}`; if (deviceId) { query += `&device_id=${deviceId}`; } await fetch(`https://api.spotify.com/v1/me/player/seek${query}`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'PUT', }); } export async function setDevice( token: string, deviceId: string, shouldPlay?: boolean, ): Promise { await fetch(`https://api.spotify.com/v1/me/player`, { body: JSON.stringify({ device_ids: [deviceId], play: shouldPlay }), headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'PUT', }); } export async function setVolume(token: string, volume: number, deviceId?: string): Promise { let query = `?volume_percent=${volume}`; if (deviceId) { query += `&device_id=${deviceId}`; } await fetch(`https://api.spotify.com/v1/me/player/volume${query}`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'PUT', }); } export async function shuffle(token: string, state: boolean, deviceId?: string): Promise { let query = `?state=${state}`; if (deviceId) { query += `&device_id=${deviceId}`; } await fetch(`https://api.spotify.com/v1/me/player/shuffle${query}`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, method: 'PUT', }); } ================================================ FILE: src/modules/styled.tsx ================================================ /* eslint-disable import/extensions */ /* tslint:disable:object-literal-sort-keys */ import { createElement, FunctionComponent } from 'react'; import { create, CssLikeObject, NanoRenderer } from 'nano-css'; // @ts-ignore import { addon as addonJSX } from 'nano-css/addon/jsx.js'; import { addon as addonKeyframes } from 'nano-css/addon/keyframes.js'; // @ts-ignore import { addon as addonNesting } from 'nano-css/addon/nesting.js'; import { addon as addonRule } from 'nano-css/addon/rule.js'; // @ts-ignore import { addon as addonStyle } from 'nano-css/addon/style.js'; // @ts-ignore import { addon as addonStyled } from 'nano-css/addon/styled.js'; import { StyledProps } from '~/types'; interface NanoExtended extends NanoRenderer { styled: ( tag: string, ) => ( styles: CssLikeObject, dynamicTemplate?: (props: StyledProps) => CssLikeObject, block?: string, ) => FunctionComponent>; } const nano = create({ h: createElement }); addonRule(nano); addonKeyframes(nano); addonJSX(nano); addonStyle(nano); addonStyled(nano); addonNesting(nano); const { keyframes, put, styled } = nano as NanoExtended; export const px = (value: string | number): string => typeof value === 'number' ? `${value}px` : value; export { keyframes, put, styled }; export { type CssLikeObject } from 'nano-css'; ================================================ FILE: src/types/common.ts ================================================ import { ReactNode } from 'react'; import { ERROR_TYPE, STATUS, TYPE } from '~/constants'; import { SpotifyDevice, SpotifyTrack } from './spotify'; export type ErrorType = (typeof ERROR_TYPE)[keyof typeof ERROR_TYPE]; export type IDs = string | string[]; export type Layout = 'responsive' | 'compact'; export type RepeatState = 'off' | 'context' | 'track'; export type Status = (typeof STATUS)[keyof typeof STATUS]; export type StylesProps = Partial; export type Type = (typeof TYPE)[keyof typeof TYPE]; export interface CallbackState extends State { type: Type; } export interface ComponentsProps { [key: string]: any; children?: ReactNode; styles: StylesOptions; } export interface CustomComponents { /** * A React component to be displayed before the previous button. */ leftButton?: ReactNode; /** * A React component to be displayed after the next button. */ rightButton?: ReactNode; } export interface Locale { currentDevice: string; devices: string; next: string; otherDevices: string; pause: string; play: string; previous: string; removeTrack: string; saveTrack: string; title: string; volume: string; } export interface PlayOptions { context_uri?: string; uris?: string[]; } export interface Props { /** * Start the player immediately. * @default false * @deprecated Most browsers block autoplaying since the user needs to interact with the page first. */ autoPlay?: boolean; /** * Get status updates from the player. */ callback?: (state: CallbackState) => any; /** * Custom components for the player. */ components?: CustomComponents; /** * The callback Spotify SDK uses to get/update the token. */ getOAuthToken?: (callback: (token: string) => void) => Promise; /** * Get the Spotify Web Playback SDK instance. */ getPlayer?: (player: Spotify.Player) => void; /** * Hide the Spotify logo. * More info: https://developer.spotify.com/documentation/general/design-and-branding/ * @default false */ hideAttribution?: boolean; /** * Hide the cover art. * @default false */ hideCoverArt?: boolean; /** * The initial volume for the player. This isn't used for external devices. * @default 1 */ initialVolume?: number; /** * Show the volume inline for the "responsive" layout for 768px and above. * @default true */ inlineVolume?: boolean; /** * The layout of the player. * @default responsive */ layout?: Layout; /** * The strings used for aria-label/title attributes. */ locale?: Partial; /** * Magnify the player's slider on hover. * @default false */ magnifySliderOnHover?: boolean; /** * The name of the player. * @default Spotify Web Player */ name?: string; /** * The position of the list/tracks you want to start the player. */ offset?: number; /** * Save the device selection. * @default false */ persistDeviceSelection?: boolean; /** * Control the player's status. */ play?: boolean; /** * Preload the track data before playing. */ preloadData?: boolean; /** * Display a Favorite button. It needs additional scopes in your token. * @default false */ showSaveIcon?: boolean; /** * Customize the player's appearance. */ styles?: StylesProps; /** * If there are no URIs and an external device is playing, use the external player context. * @default false */ syncExternalDevice?: boolean; /** * The time in seconds that the player will sync with external devices. * @default 5 */ syncExternalDeviceInterval?: number; /** * A Spotify token. */ token: string; /** * Provide you with a function to sync the track saved status in the player. * This works in addition to the showSaveIcon prop, and it is only needed if you keep the track's saved status in your app. */ updateSavedStatus?: (fn: (status: boolean) => any) => any; /** * A list of Spotify URIs. */ uris: string | string[]; } export interface State { currentDeviceId: string; currentURI: string; deviceId: string; devices: SpotifyDevice[]; error: string; errorType: ErrorType | null; isActive: boolean; isInitializing: boolean; isMagnified: boolean; isPlaying: boolean; isSaved: boolean; isUnsupported: boolean; needsUpdate: boolean; nextTracks: SpotifyTrack[]; playerPosition: 'bottom' | 'top'; position: number; previousTracks: SpotifyTrack[]; progressMs: number; repeat: RepeatState; shuffle: boolean; status: Status; track: SpotifyTrack; volume: number; } export interface StyledProps { [key: string]: any; style: Record; } export interface StylesOptions { activeColor: string; bgColor: string; color: string; errorColor: string; height: number; loaderColor: string; loaderSize: number | string; sliderColor: string; sliderHandleBorderRadius: number | string; sliderHandleColor: string; sliderHeight: number; sliderTrackBorderRadius: number | string; sliderTrackColor: string; trackArtistColor: string; trackNameColor: string; } ================================================ FILE: src/types/index.ts ================================================ export * from './common'; export * from './spotify'; ================================================ FILE: src/types/spotify.ts ================================================ export type SpotifyAlbum = Spotify.Album; export type SpotifyArtist = SpotifyApi.ArtistObjectSimplified; export type SpotifyDevice = SpotifyApi.UserDevice; export type SpotifyPlayerCallback = (token: string) => void; export interface SpotifyPlayOptions { context_uri?: string; deviceId: string; offset?: number; uris?: string[]; } export interface SpotifyTrack { artists: Pick[]; durationMs: number; id: string; image: string; name: string; uri: string; } export interface WebPlaybackArtist { name: string; uri: string; } ================================================ FILE: test/__setup__/global.d.ts ================================================ import 'jest-extended'; import 'vitest/globals'; ================================================ FILE: test/__setup__/vitest.setup.ts ================================================ import '@testing-library/jest-dom'; import { configure } from '@testing-library/react'; import * as matchers from 'jest-extended'; import { vi } from 'vitest'; import createFetchMock from 'vitest-fetch-mock'; configure({ testIdAttribute: 'data-component-name' }); expect.extend(matchers); const fetchMock = createFetchMock(vi); fetchMock.enableMocks(); Object.defineProperty(window, 'matchMedia', { writable: true, value: (query: string) => ({ matches: true, media: query, onchange: null, addListener: vi.fn(), // deprecated removeListener: vi.fn(), // deprecated addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), }), }); ================================================ FILE: test/__snapshots__/constants.spec.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`ERROR_TYPE > should have all options 1`] = ` { "ACCOUNT": "account", "AUTHENTICATION": "authentication", "INITIALIZATION": "initialization", "PLAYBACK": "playback", "PLAYER": "player", } `; exports[`SPOTIFY_CONTENT_TYPE > should have all options 1`] = ` { "ALBUM": "album", "ARTIST": "artist", "PLAYLIST": "playlist", "SHOW": "show", "TRACK": "track", } `; exports[`STATUS > should have all options 1`] = ` { "ERROR": "ERROR", "IDLE": "IDLE", "INITIALIZING": "INITIALIZING", "READY": "READY", "RUNNING": "RUNNING", "UNSUPPORTED": "UNSUPPORTED", } `; exports[`TYPE > should have all options 1`] = ` { "DEVICE": "device_update", "FAVORITE": "favorite_update", "PLAYER": "player_update", "PRELOAD": "preload_update", "PROGRESS": "progress_update", "STATUS": "status_update", "TRACK": "track_update", } `; ================================================ FILE: test/__snapshots__/index.spec.tsx.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`SpotifyWebPlayer > Device listeners > should handle \`player_state_changed\` 1`] = `
0:00
2:31
`; exports[`SpotifyWebPlayer > Device listeners > should handle \`ready\` 1`] = `
0:00
0:00
`; exports[`SpotifyWebPlayer > Error listeners > should handle \`account_error\` 1`] = `
Failed to validate Spotify account
`; exports[`SpotifyWebPlayer > Error listeners > should handle \`authentication_error\` > With Error 1`] = `
Failed to authenticate
`; exports[`SpotifyWebPlayer > Error listeners > should handle \`initialization_error\` 1`] = `

Select other device

0:00
0:00
`; exports[`SpotifyWebPlayer > Error listeners > should handle \`playback_error\` 1`] = `
0:00
2:31
`; exports[`SpotifyWebPlayer > With "compact" layout > should render properly 1`] = `
0:00
2:31
`; exports[`SpotifyWebPlayer > With "components" prop > should render the components 1`] = `
0:00
2:31
`; exports[`SpotifyWebPlayer > With the local player > should render the full UI 1`] = `
0:00
2:31
`; ================================================ FILE: test/constants.spec.ts ================================================ import { ERROR_TYPE, SPOTIFY_CONTENT_TYPE, STATUS, TRANSPARENT_COLOR, TYPE } from '~/constants'; describe('ERROR_TYPE', () => { it('should have all options', () => { expect(ERROR_TYPE).toMatchSnapshot(); }); }); describe('SPOTIFY_CONTENT_TYPE', () => { it('should have all options', () => { expect(SPOTIFY_CONTENT_TYPE).toMatchSnapshot(); }); }); describe('STATUS', () => { it('should have all options', () => { expect(STATUS).toMatchSnapshot(); }); }); describe('TRANSPARENT_COLOR', () => { it('should have all options', () => { expect(TRANSPARENT_COLOR).toBe('rgba(0, 0, 0, 0)'); }); }); describe('TYPE', () => { it('should have all options', () => { expect(TYPE).toMatchSnapshot(); }); }); ================================================ FILE: test/fixtures/data.ts ================================================ export const playerAlbum = { images: [ { height: 298, url: 'https://i.scdn.co/image/177f29ea8006359bd70784a803a21fea0360ca3e', width: 300, }, { height: 64, url: 'https://i.scdn.co/image/38ff482faf9916ca15ccb3e14b2886a27c0866e3', width: 64, }, { height: 636, url: 'https://i.scdn.co/image/10b3bd8afaf3dfa1f302b8f58e059e9802144052', width: 640, }, ], name: 'Trouble Man', uri: 'spotify:album:7KvKuWUxxNPEU80c4i5AQk', }; export const playerArtists = [ { name: 'Marvin Gaye', uri: 'spotify:artist:3koiLjNrgRTNbOwViDipeA', }, ]; export const playerTrack = { album: playerAlbum, artists: playerArtists, duration_ms: 151626, id: '6KUjwoHktuX3du8laPVfO8', is_playable: true, linked_from: { id: null, uri: null, }, linked_from_uri: null, media_type: 'audio', name: 'Main Theme From Trouble Man', type: 'track', uri: 'spotify:track:6KUjwoHktuX3du8laPVfO8', }; export const playerArtistTopTracks = { tracks: [playerTrack], }; export const playerAlbumTracks = { items: [playerTrack], }; export const playerPlaylistTracks = { items: [{ track: playerTrack }], }; export const playerShow = { description: 'A Tasty Treat Podcast for Web Developers', html_description: 'A Tasty Treat Podcast for Web Developers', explicit: false, external_urls: { spotify: 'https://open.spotify.com/show/4kYCRYJ3yK5DQbP5tbfZby', }, href: 'https://api.spotify.com/v1/shows/4kYCRYJ3yK5DQbP5tbfZby?locale=en-US%2Cen%3Bq%3D0.9%2Cpt-BR%3Bq%3D0.8%2Cpt%3Bq%3D0.7', id: '4kYCRYJ3yK5DQbP5tbfZby', images: [ { height: 640, url: 'https://i.scdn.co/image/ab6765630000ba8ada4bfc6d17ba4b7f66e6012a', width: 640, }, { height: 300, url: 'https://i.scdn.co/image/ab67656300005f1fda4bfc6d17ba4b7f66e6012a', width: 300, }, { height: 64, url: 'https://i.scdn.co/image/ab6765630000f68dda4bfc6d17ba4b7f66e6012a', width: 64, }, ], is_externally_hosted: false, languages: ['en'], media_type: 'mixed', name: 'Syntax - Tasty Web Development Treats', publisher: 'Wes Bos and Scott Tolinski', type: 'show', uri: 'spotify:show:4kYCRYJ3yK5DQbP5tbfZby', total_episodes: 847, episodes: { href: 'https://api.spotify.com/v1/shows/4kYCRYJ3yK5DQbP5tbfZby/episodes?offset=0&limit=50&locale=en-US,en;q%3D0.9,pt-BR;q%3D0.8,pt;q%3D0.7', limit: 50, next: 'https://api.spotify.com/v1/shows/4kYCRYJ3yK5DQbP5tbfZby/episodes?offset=50&limit=50&locale=en-US,en;q%3D0.9,pt-BR;q%3D0.8,pt;q%3D0.7', offset: 0, previous: null, total: 847, items: [ { audio_preview_url: 'https://podz-content.spotifycdn.com/audio/clips/0qbdMJHfDdYKgaV4fnOF8w/clip_2557400_2604150.mp3', description: ' Scott and Wes unpack their experiences as electric car owners, sharing the highs and lows of making the switch. From range anxiety to charging infrastructure and cost savings, they talk about everything from the tech perks to the unexpected challenges of driving electric. Show Notes 00:00 Welcome to Syntax! 02:11 Brought to you by Sentry.io. 03:14 What cars and how long have we had them. Hyundai IONIQ 5. Tesla Model Y Long Range. 10:41 Range and dealing with range anxiety. 11:45 The EPA specs. 12:24 Things that affect range. 14:46 Charging. 17:52 Charging levels. 17:56 Level 1 charging. 19:01 Level 2 charging. 19:39 Level 3 charging. 20:10 Charging standards. 21:51 Electric car pricing. 25:56 Regenerative braking. 27:27 General maintenance. 29:04 Pricing and expenses. 31:48 Machine Gun Kelly Effect. 36:46 Would you go completely electric? 38:46 Electric-only tech. 40:57 Buying a new EV. 42:21 Edison Motors website, TikTok. Hit us up on Socials! Syntax: X Instagram Tiktok LinkedIn Threads Wes: X Instagram Tiktok LinkedIn Threads Scott: X Instagram Tiktok LinkedIn Threads Randy: X Instagram YouTube Threads', html_description: '

Scott and Wes unpack their experiences as electric car owners, sharing the highs and lows of making the switch. From range anxiety to charging infrastructure and cost savings, they talk about everything from the tech perks to the unexpected challenges of driving electric.

Show Notes

Hit us up on Socials!

Syntax: X Instagram Tiktok LinkedIn Threads

Wes: X Instagram Tiktok LinkedIn Threads

Scott: X Instagram Tiktok LinkedIn Threads

Randy: X Instagram YouTube Threads

', duration_ms: 2645609, explicit: false, external_urls: { spotify: 'https://open.spotify.com/episode/5cTJgzcfadfcfPzvJAwJk1', }, href: 'https://api.spotify.com/v1/episodes/5cTJgzcfadfcfPzvJAwJk1', id: '5cTJgzcfadfcfPzvJAwJk1', images: [ { url: 'https://i.scdn.co/image/ab6765630000ba8ada4bfc6d17ba4b7f66e6012a', height: 640, width: 640, }, { url: 'https://i.scdn.co/image/ab67656300005f1fda4bfc6d17ba4b7f66e6012a', height: 300, width: 300, }, { url: 'https://i.scdn.co/image/ab6765630000f68dda4bfc6d17ba4b7f66e6012a', height: 64, width: 64, }, ], is_externally_hosted: false, is_playable: true, language: 'en', languages: ['en'], name: '846: Talking EVs: Range Anxiety, Charging, and Tech', release_date: '2024-11-11', release_date_precision: 'day', resume_point: { fully_played: false, resume_position_ms: 0, }, type: 'episode', uri: 'spotify:episode:5cTJgzcfadfcfPzvJAwJk1', }, ], }, }; export const playerState = { bitrate: 256000, context: { metadata: { context_description: 'Trouble Man', }, uri: 'spotify:album:7KvKuWUxxNPEU80c4i5AQk', }, disallows: { resuming: true, skipping_prev: true, }, duration: 151626, paused: true, position: 0, repeat_mode: 0, restrictions: { disallow_resuming_reasons: ['not_paused'], disallow_skipping_prev_reasons: ['no_prev_track'], }, shuffle: false, timestamp: 1556483439737, track_window: { current_track: playerTrack, next_tracks: [ { ...playerTrack, name: 'Next Theme From Trouble Man', }, ], previous_tracks: [ { ...playerTrack, name: 'Previous Theme From Trouble Man', }, ], }, }; export const playbackState = { actions: { disallows: { resuming: true, skipping_prev: true, }, }, context: { external_urls: { spotify: 'https://open.spotify.com/album/7KvKuWUxxNPEU80c4i5AQk', }, href: 'https://api.spotify.com/v1/albums/7KvKuWUxxNPEU80c4i5AQk', type: 'album', uri: 'spotify:album:7KvKuWUxxNPEU80c4i5AQk', }, currently_playing_type: 'track', device: { id: '84944e58544c5d9ebfa1b9aa1f1890fb03c42250', is_active: true, is_private_session: false, is_restricted: false, name: 'Spotify Web Player', type: 'Computer', volume_percent: 100, }, is_playing: false, item: { album: { album_type: 'album', artists: [ { external_urls: { spotify: 'https://open.spotify.com/artist/3koiLjNrgRTNbOwViDipeA', }, href: 'https://api.spotify.com/v1/artists/3koiLjNrgRTNbOwViDipeA', id: '3koiLjNrgRTNbOwViDipeA', name: 'Marvin Gaye', type: 'artist', uri: 'spotify:artist:3koiLjNrgRTNbOwViDipeA', }, ], available_markets: [], external_urls: { spotify: 'https://open.spotify.com/album/7KvKuWUxxNPEU80c4i5AQk', }, href: 'https://api.spotify.com/v1/albums/7KvKuWUxxNPEU80c4i5AQk', id: '7KvKuWUxxNPEU80c4i5AQk', images: [ { height: 636, url: 'https://i.scdn.co/image/10b3bd8afaf3dfa1f302b8f58e059e9802144052', width: 640, }, { height: 298, url: 'https://i.scdn.co/image/177f29ea8006359bd70784a803a21fea0360ca3e', width: 300, }, { height: 64, url: 'https://i.scdn.co/image/38ff482faf9916ca15ccb3e14b2886a27c0866e3', width: 64, }, ], name: 'Trouble Man', release_date: '1972-12-08', release_date_precision: 'day', total_tracks: 13, type: 'album', uri: 'spotify:album:7KvKuWUxxNPEU80c4i5AQk', }, artists: [ { external_urls: { spotify: 'https://open.spotify.com/artist/3koiLjNrgRTNbOwViDipeA', }, href: 'https://api.spotify.com/v1/artists/3koiLjNrgRTNbOwViDipeA', id: '3koiLjNrgRTNbOwViDipeA', name: 'Marvin Gaye', type: 'artist', uri: 'spotify:artist:3koiLjNrgRTNbOwViDipeA', }, ], available_markets: [], disc_number: 1, duration_ms: 151626, explicit: false, external_ids: { isrc: 'USMO17200009', }, external_urls: { spotify: 'https://open.spotify.com/track/6KUjwoHktuX3du8laPVfO8', }, href: 'https://api.spotify.com/v1/tracks/6KUjwoHktuX3du8laPVfO8', id: '6KUjwoHktuX3du8laPVfO8', is_local: false, name: 'Main Theme From Trouble Man - 2', popularity: 27, preview_url: 'https://p.scdn.co/mp3-preview/ec9f4fcea45b0665dd162b69004271fe55174566?cid=adaaf209fb064dfab873a71817029e0d', track_number: 1, type: 'track', uri: 'spotify:track:6KUjwoHktuX3du8laPVfO8', }, progress_ms: 10443, repeat_state: 'off', shuffle_state: false, timestamp: 1557288761568, }; export const player = {}; export const queue = { currently_playing: { album: { album_type: 'compilation', total_tracks: 9, available_markets: ['CA', 'BR', 'IT'], external_urls: { spotify: 'string', }, href: 'string', id: '2up3OPMp9Tb4dAKM2erWXQ', images: [ { url: 'https://i.scdn.co/image/ab67616d00001e02ff9ca10b55ce82ae553c8228', height: 300, width: 300, }, ], name: 'string', release_date: '1981-12', release_date_precision: 'year', restrictions: { reason: 'market', }, type: 'album', uri: 'spotify:album:2up3OPMp9Tb4dAKM2erWXQ', copyrights: [ { text: 'string', type: 'string', }, ], external_ids: { isrc: 'string', ean: 'string', upc: 'string', }, genres: ['Egg punk', 'Noise rock'], label: 'string', popularity: 0, album_group: 'compilation', artists: [ { external_urls: { spotify: 'string', }, href: 'string', id: 'string', name: 'string', type: 'artist', uri: 'string', }, ], }, artists: [ { external_urls: { spotify: 'string', }, followers: { href: 'string', total: 0, }, genres: ['Prog rock', 'Grunge'], href: 'string', id: 'string', images: [ { url: 'https://i.scdn.co/image/ab67616d00001e02ff9ca10b55ce82ae553c8228', height: 300, width: 300, }, ], name: 'string', popularity: 0, type: 'artist', uri: 'string', }, ], available_markets: ['string'], disc_number: 0, duration_ms: 0, explicit: false, external_ids: { isrc: 'string', ean: 'string', upc: 'string', }, external_urls: { spotify: 'string', }, href: 'string', id: 'string', is_playable: false, linked_from: {}, restrictions: { reason: 'string', }, name: 'string', popularity: 0, preview_url: 'string', track_number: 0, type: 'track', uri: 'string', is_local: false, }, queue: [ { album: { album_type: 'compilation', total_tracks: 9, available_markets: ['CA', 'BR', 'IT'], external_urls: { spotify: 'string', }, href: 'string', id: '2up3OPMp9Tb4dAKM2erWXQ', images: [ { url: 'https://i.scdn.co/image/ab67616d00001e02ff9ca10b55ce82ae553c8228', height: 300, width: 300, }, ], name: 'string', release_date: '1981-12', release_date_precision: 'year', restrictions: { reason: 'market', }, type: 'album', uri: 'spotify:album:2up3OPMp9Tb4dAKM2erWXQ', copyrights: [ { text: 'string', type: 'string', }, ], external_ids: { isrc: 'string', ean: 'string', upc: 'string', }, genres: ['Egg punk', 'Noise rock'], label: 'string', popularity: 0, album_group: 'compilation', artists: [ { external_urls: { spotify: 'string', }, href: 'string', id: 'string', name: 'string', type: 'artist', uri: 'string', }, ], }, artists: [ { external_urls: { spotify: 'string', }, followers: { href: 'string', total: 0, }, genres: ['Prog rock', 'Grunge'], href: 'string', id: 'string', images: [ { url: 'https://i.scdn.co/image/ab67616d00001e02ff9ca10b55ce82ae553c8228', height: 300, width: 300, }, ], name: 'string', popularity: 0, type: 'artist', uri: 'string', }, ], available_markets: ['string'], disc_number: 0, duration_ms: 0, explicit: false, external_ids: { isrc: 'string', ean: 'string', upc: 'string', }, external_urls: { spotify: 'string', }, href: 'string', id: 'string', is_playable: false, linked_from: {}, restrictions: { reason: 'string', }, name: 'string', popularity: 0, preview_url: 'string', track_number: 0, type: 'track', uri: 'string', is_local: false, }, ], }; ================================================ FILE: test/fixtures/helpers.ts ================================================ export const domRect = { slider: { bottom: 50, height: 6, left: 0, right: 0, top: 0, width: 1024, }, volume: { bottom: 50, height: 50, left: 900, right: 0, top: 0, width: 6, }, volumeInline: { bottom: 930, height: 4, left: 900, right: 1000, top: 926, width: 100, }, }; export function setBoundingClientRect(type: 'slider' | 'volume' | 'volumeInline') { // @ts-ignore Element.prototype.getBoundingClientRect = () => domRect[type]; } ================================================ FILE: test/index.spec.tsx ================================================ /* eslint-disable testing-library/no-unnecessary-act */ import React from 'react'; import { act, fireEvent, render, screen, waitFor as testingLibraryWaitFor, within, } from '@testing-library/react'; import SpotifyWebPlayer, { Props } from '~/index'; import * as helpers from '~/modules/helpers'; import { playbackState, playerAlbumTracks, playerState, playerTrack } from './fixtures/data'; import { setBoundingClientRect } from './fixtures/helpers'; vi.spyOn(helpers, 'loadSpotifyPlayer').mockImplementation(() => Promise.resolve()); vi.useFakeTimers(); let playerStateResponse = playerState; let playerStatusResponse = playbackState; async function waitFor(fn: () => void) { vi.useRealTimers(); await testingLibraryWaitFor(fn); vi.useFakeTimers(); } const mockAddListener = vi.fn(); const initializePlayer = async () => { const [, readyFn] = mockAddListener.mock.calls.find(d => d[0] === 'ready')!; await readyFn({ device_id: deviceId }); }; const updatePlayer = async (state?: Partial) => { const [, stateChangeFn] = mockAddListener.mock.calls.find(d => d[0] === 'player_state_changed')!; await stateChangeFn({ ...playerState, ...state }); }; const mockFn = vi.fn(); const mockActivateElement = vi.fn(); const mockCallback = vi.fn(); const mockConnect = vi.fn(); const mockDisconnect = vi.fn(); const mockGetCurrentState = vi.fn(() => playerStateResponse); const mockGetOAuthToken = vi.fn(); const mockGetVolume = vi.fn(() => 1); const mockNextTrack = vi.fn(updatePlayer); const mockPreviousTrack = vi.fn(updatePlayer); const mockRemoveListener = vi.fn(); const mockSetName = vi.fn(); const mockSetVolume = vi.fn(); const mockTogglePlay = vi.fn(updatePlayer); const deviceId = '19ks98hfbxc53vh34jd'; const externalDeviceId = 'df17372ghs982js892js'; const token = 'BQDoGCFtLXDAVgphhrRSPFHmhG9ZND3BLzSE5WVE-2qoe7_YZzRcVtZ6F7qEhzTih45GyxZLhp9b53A1YAPObAgV0MDvsbcQg-gZzlrIeQwwsWnz3uulVvPMhqssNP5HnE5SX0P0wTOOta1vneq2dL4Hvdko5WqvRivrEKWXCvJTPAFStfa5V5iLdCSglg'; const trackUris = ['spotify:track:2ViHeieFA3iPmsBya2NDFl', 'spotify:track:5zq709Rk69kjzCDdNthSbK']; const baseProps = { callback: mockCallback, token, uris: 'spotify:album:7KvKuWUxxNPEU80c4i5AQk', }; interface SetupProps extends Partial { initialize?: boolean; skipUpdate?: boolean; updateState?: Partial; } class Player { _options: any; constructor(options: Record) { options.getOAuthToken(mockGetOAuthToken); // eslint-disable-next-line no-underscore-dangle this._options = options; } activateElement = mockActivateElement; addListener = mockAddListener; connect = mockConnect; disconnect = mockDisconnect; getCurrentState = mockGetCurrentState; getVolume = mockGetVolume; nextTrack = mockNextTrack; on = mockFn; pause = mockFn; previousTrack = mockPreviousTrack; removeListener = mockRemoveListener; resume = mockFn; seek = mockFn; setName = mockSetName; setVolume = mockSetVolume; togglePlay = mockTogglePlay; } function setExternalDevice() { // open the device selector fireEvent.click(screen.getByLabelText('Devices')); // select the external device fireEvent.click(screen.getByLabelText('Test Player')); } async function setup(props?: SetupProps) { const { initialize = true, skipUpdate = false, updateState, ...rest } = props || {}; const view = render(); await act(async () => { window.onSpotifyWebPlaybackSDKReady(); }); if (initialize) { await act(async () => { await initializePlayer(); if (!skipUpdate) { await updatePlayer(updateState); } }); } return view; } describe('SpotifyWebPlayer', () => { beforeAll(async () => { window.Spotify = { // @ts-expect-error Mock Player, }; fetchMock.mockIf(/.*/, request => { const { method, url } = request; if (url.match(/contains\?ids=*/)) { return Promise.resolve({ body: JSON.stringify([false]), }); } else if (url.match(/album/)) { return Promise.resolve({ body: JSON.stringify(playerAlbumTracks), }); } else if (url.match(/track/)) { return Promise.resolve({ body: JSON.stringify(playerTrack), }); } else if (url === 'https://api.spotify.com/v1/me/player/devices') { return Promise.resolve({ body: JSON.stringify({ devices: [ { id: externalDeviceId, name: 'Test Player', type: 'Computer', }, ], }), }); } else if (url === 'https://api.spotify.com/v1/me/player') { return Promise.resolve({ body: JSON.stringify(playerStatusResponse), }); } else if (method === 'GET' && url === 'https://api.spotify.com/v1/me/tracks') { return Promise.resolve({ status: 200 }); } else if (method === 'PUT' && url === 'https://api.spotify.com/v1/me/tracks') { return Promise.resolve({ status: 200 }); } else if (['POST', 'PUT'].includes(request.method)) { return Promise.resolve({ status: 204 }); } return Promise.resolve({ status: 404 }); }); }); afterEach(() => { vi.clearAllMocks(); }); describe('Error listeners', () => { beforeAll(() => { vi.clearAllMocks(); }); it('should handle `authentication_error`', async () => { const { rerender, unmount } = await setup({ initialize: false }); const [authenticationType, authenticationFn] = mockAddListener.mock.calls.find( d => d[0] === 'authentication_error', )!; await act(async () => { authenticationFn({ type: authenticationType, message: 'Failed to authenticate' }); }); expect(screen.getByTestId('Player')).toHaveAttribute('data-ready', 'false'); expect(screen.getByTestId('Player')).toMatchSnapshot('With Error'); expect(mockDisconnect).toHaveBeenCalledTimes(1); rerender(); await act(async () => { await initializePlayer(); }); expect(screen.getByTestId('Player')).toHaveAttribute('data-ready', 'true'); unmount(); }); it('should handle `account_error`', async () => { await setup({ initialize: false }); const [accountType, accountFn] = mockAddListener.mock.calls.find( d => d[0] === 'account_error', )!; await act(async () => { accountFn({ type: accountType, message: 'Failed to validate Spotify account' }); }); expect(screen.getByTestId('Player')).toMatchSnapshot(); expect(mockDisconnect).toHaveBeenCalledTimes(1); }); it('should handle `initialization_error`', async () => { await setup({ initialize: false }); const [initializationType, initializationFn] = mockAddListener.mock.calls.find( d => d[0] === 'initialization_error', )!; await act(async () => { initializationFn({ type: initializationType, message: 'Failed to initialize' }); }); expect(screen.getByTestId('Player')).toMatchSnapshot(); expect(mockDisconnect).toHaveBeenCalledTimes(1); }); it('should handle `playback_error`', async () => { const { unmount } = await setup({ initialize: true }); const [playbackType, playbackFn] = mockAddListener.mock.calls.find( d => d[0] === 'playback_error', )!; await act(async () => { playbackFn({ type: playbackType, message: 'Failed to perform playback' }); }); expect(screen.getByTestId('Player')).toMatchSnapshot(); expect(mockDisconnect).not.toHaveBeenCalled(); unmount(); expect(mockDisconnect).toHaveBeenCalledTimes(1); }); }); describe('Device listeners', () => { beforeAll(() => { vi.clearAllMocks(); }); it('should handle `ready`', async () => { await setup({ skipUpdate: true }); expect(screen.getByTestId('Player')).toMatchSnapshot(); }); it('should handle `player_state_changed`', async () => { await setup({ updateState: { paused: false, }, }); await waitFor(() => { expect(screen.getByLabelText('Pause')).toBeInTheDocument(); }); expect(screen.getByTestId('Player')).toMatchSnapshot(); }); }); describe('With the local player', () => { const props = { autoPlay: true, showSaveIcon: true }; beforeAll(() => { vi.clearAllMocks(); }); it('should initialize the token', async () => { await setup(props); expect(mockGetOAuthToken).toHaveBeenCalledWith(token); }); it('should render a loader while initializing', async () => { await setup({ initialize: false }); expect(screen.getByTestId('Loader')).toBeInTheDocument(); }); it('should render the full UI', async () => { await setup(props); expect(screen.getByTestId('Player')).toMatchSnapshot(); }); it('should handle range changes', async () => { setBoundingClientRect('slider'); await setup(props); const range = screen.getByTestId('Slider'); // eslint-disable-next-line testing-library/no-node-access fireEvent.click(range.querySelector('.slider__track')!, { clientX: 410, clientY: 718, currentTarget: {}, }); await waitFor(() => { expect(screen.getByTestId('Slider')).toHaveAttribute('data-position', '40'); }); }); it('should handle range magnification', async () => { await setup({ ...props, magnifySliderOnHover: true }); const progressBar = screen.getByTestId('progress-bar'); expect(screen.getAllByLabelText('slider handle')[0]).toHaveStyle({ height: '10px', }); // eslint-disable-next-line testing-library/no-node-access fireEvent.mouseEnter(progressBar.querySelector('.slider__track')!); expect(within(progressBar).getByLabelText('slider handle')).toHaveStyle({ height: '14px', }); // eslint-disable-next-line testing-library/no-node-access fireEvent.mouseLeave(progressBar.querySelector('.slider__track')!); expect(within(progressBar).getByLabelText('slider handle')).toHaveStyle({ height: '10px', }); }); it('should handle Volume changes', async () => { setBoundingClientRect('volume'); await setup({ ...props, inlineVolume: false }); expect(screen.getByTestId('VolumeHigh')).toBeInTheDocument(); fireEvent.click(screen.getByLabelText('Volume')); // eslint-disable-next-line testing-library/no-node-access fireEvent.click(screen.getByTestId('Volume').querySelector('.volume__track')!, { clientX: 910, clientY: 25, currentTarget: {}, }); await act(async () => { vi.runOnlyPendingTimers(); }); expect(mockSetVolume).toHaveBeenCalledWith(0.5); expect(screen.getByTestId('Volume')).toHaveAttribute('data-value', '0.5'); expect(screen.getByTestId('VolumeMid')).toBeInTheDocument(); fireEvent.click(screen.getByLabelText('Volume')); // eslint-disable-next-line testing-library/no-node-access fireEvent.click(screen.getByTestId('Volume').querySelector('.volume__track')!, { clientX: 910, clientY: 35, currentTarget: {}, }); await act(async () => { vi.runOnlyPendingTimers(); }); expect(mockSetVolume).toHaveBeenCalledWith(0.3); expect(screen.getByTestId('Volume')).toHaveAttribute('data-value', '0.3'); expect(screen.getByTestId('VolumeLow')).toBeInTheDocument(); fireEvent.click(screen.getByLabelText('Volume')); // eslint-disable-next-line testing-library/no-node-access fireEvent.click(screen.getByTestId('Volume').querySelector('.volume__track')!, { clientX: 910, clientY: 50, currentTarget: {}, }); await act(async () => { vi.runOnlyPendingTimers(); }); expect(mockSetVolume).toHaveBeenCalledWith(0); expect(screen.getByTestId('Volume')).toHaveAttribute('data-value', '0'); expect(screen.getByTestId('VolumeMute')).toBeInTheDocument(); }); it('should handle Volume changes with "inlineVolume"', async () => { setBoundingClientRect('volumeInline'); await setup(props); expect(screen.getByTestId('VolumeHigh')).toBeInTheDocument(); // eslint-disable-next-line testing-library/no-node-access const getTrack = () => screen.getByTestId('Volume').querySelector('.volume__track')!; fireEvent.click(getTrack(), { clientX: 950, clientY: 52, currentTarget: {}, }); await act(async () => { vi.runOnlyPendingTimers(); }); expect(mockSetVolume).toHaveBeenCalledWith(0.5); expect(screen.getByTestId('Volume')).toHaveAttribute('data-value', '0.5'); expect(screen.getByTestId('VolumeMid')).toBeInTheDocument(); fireEvent.click(getTrack(), { clientX: 930, clientY: 52, currentTarget: {}, }); await act(async () => { vi.runOnlyPendingTimers(); }); expect(mockSetVolume).toHaveBeenCalledWith(0.3); expect(screen.getByTestId('Volume')).toHaveAttribute('data-value', '0.3'); expect(screen.getByTestId('VolumeLow')).toBeInTheDocument(); fireEvent.click(getTrack(), { clientX: 900, clientY: 52, currentTarget: {}, }); await act(async () => { vi.runOnlyPendingTimers(); }); expect(mockSetVolume).toHaveBeenCalledWith(0); expect(screen.getByTestId('Volume')).toHaveAttribute('data-value', '0'); expect(screen.getByTestId('VolumeMute')).toBeInTheDocument(); }); it('should handle repeat changes', async () => { await setup({ initialize: true, updateState: { repeat_mode: 1, }, }); await waitFor(() => { expect(mockCallback).toHaveBeenLastCalledWith( expect.objectContaining({ repeat: 'context' }), ); }); }); it('should handle shuffle changes', async () => { await setup({ initialize: true, updateState: { shuffle: true, }, }); await waitFor(() => { expect(mockCallback).toHaveBeenLastCalledWith(expect.objectContaining({ shuffle: true })); }); }); it('should handle URIs changes', async () => { const { rerender } = await setup(props); rerender(); expect(fetchMock).toHaveBeenLastCalledWith( 'https://api.spotify.com/v1/me/player/play?device_id=19ks98hfbxc53vh34jd', expect.any(Object), ); expect(mockTogglePlay).toHaveBeenCalledTimes(1); await waitFor(() => { expect(screen.getByTestId('Controls')).toHaveAttribute('data-playing', 'false'); }); }); it('should handle Control clicks', async () => { await setup(props); // Click the previous track fireEvent.click(screen.getByLabelText('Previous')); expect(mockPreviousTrack).toHaveBeenCalled(); // Play the next track fireEvent.click(screen.getByLabelText('Next')); expect(mockNextTrack).toHaveBeenCalled(); fireEvent.click(screen.getByLabelText('Pause')); await waitFor(() => { expect(screen.getByTestId('Controls')).toHaveAttribute('data-playing', 'false'); }); }); it('should handle Info clicks', async () => { await setup(props); fireEvent.click(screen.getByLabelText('Save to your favorites')); expect(fetchMock).toHaveBeenLastCalledWith( 'https://api.spotify.com/v1/me/tracks', expect.objectContaining({ method: 'PUT' }), ); await waitFor(() => { expect(screen.getByLabelText('Remove from your favorites')).toBeInTheDocument(); }); }); }); describe('With an external device', () => { const props: SetupProps = { autoPlay: true, showSaveIcon: true }; it('should handle Device selection', async () => { await setup(props); setExternalDevice(); expect(screen.getByTestId('Devices')).toHaveAttribute('data-device-id', externalDeviceId); expect(fetchMock).toHaveBeenLastCalledWith( 'https://api.spotify.com/v1/me/player', expect.objectContaining({ method: 'GET' }), ); }); it('should handle Volume changes', async () => { setBoundingClientRect('volumeInline'); playerStatusResponse = { ...playbackState, device: { ...playbackState.device, volume_percent: 60, }, }; await setup(props); setExternalDevice(); // eslint-disable-next-line testing-library/no-node-access fireEvent.click(screen.getByTestId('Volume').querySelector('.volume__track')!, { clientX: 950, clientY: 50, currentTarget: {}, }); await act(async () => { vi.runOnlyPendingTimers(); }); expect(fetchMock).toHaveBeenCalledWith( 'https://api.spotify.com/v1/me/player/volume?volume_percent=50', expect.objectContaining({ method: 'PUT' }), ); expect(screen.getByTestId('Volume')).toHaveAttribute('data-value', '0.5'); }); it('should handle Control clicks', async () => { // reset the response (playing) playerStatusResponse = { ...playbackState, is_playing: true, }; await setup({ ...props, autoPlay: false }); setExternalDevice(); fireEvent.click(screen.getByLabelText('Play')); expect(fetchMock).toHaveBeenLastCalledWith( `https://api.spotify.com/v1/me/player/play?device_id=${externalDeviceId}`, expect.any(Object), ); await waitFor(() => { expect(screen.getByTestId('Controls')).toHaveAttribute('data-playing', 'true'); }); // Play the previous track await act(async () => { fireEvent.click(screen.getByLabelText('Previous')); }); expect(fetchMock).toHaveBeenLastCalledWith( 'https://api.spotify.com/v1/me/player/previous', expect.any(Object), ); await act(async () => { vi.runOnlyPendingTimers(); }); expect(fetchMock).toHaveBeenLastCalledWith( 'https://api.spotify.com/v1/me/player', expect.any(Object), ); // Play the next track await act(async () => { fireEvent.click(screen.getByLabelText('Next')); }); expect(fetchMock).toHaveBeenLastCalledWith( 'https://api.spotify.com/v1/me/player/next', expect.any(Object), ); await act(async () => { vi.runOnlyPendingTimers(); }); expect(fetchMock).toHaveBeenLastCalledWith( 'https://api.spotify.com/v1/me/player', expect.any(Object), ); // Pause the player await act(async () => { fireEvent.click(screen.getByLabelText('Pause')); }); expect(fetchMock).toHaveBeenLastCalledWith( 'https://api.spotify.com/v1/me/player/pause', expect.any(Object), ); // reset the response again (paused) playerStatusResponse = playbackState; await act(async () => { vi.runOnlyPendingTimers(); }); expect(fetchMock).toHaveBeenLastCalledWith( 'https://api.spotify.com/v1/me/player', expect.any(Object), ); await waitFor(() => { expect(screen.getByTestId('Controls')).toHaveAttribute('data-playing', 'false'); }); }); }); describe('With "persistDeviceSelection"', () => { it('should handle "persistDeviceSelection"', async () => { await setup({ persistDeviceSelection: true }); expect(sessionStorage.getItem('rswpDeviceId')).toBe(deviceId); setExternalDevice(); await waitFor(() => { expect(sessionStorage.getItem('rswpDeviceId')).toBe(externalDeviceId); }); }); }); describe('With "syncExternalDevice"', () => { it('should handle syncExternalDevice changes', async () => { playerStatusResponse = { ...playbackState, is_playing: true, }; await setup({ syncExternalDevice: true, uris: undefined }); expect(screen.getByTestId('Devices')).toHaveAttribute( 'data-device-id', playbackState.device.id, ); }); }); describe('With control props', () => { it('should honor the "play" prop', async () => { playerStatusResponse = { ...playbackState, is_playing: true, }; const { rerender } = await setup({ play: true }); expect(screen.getByTestId('Controls')).toHaveAttribute('data-playing', 'true'); playerStateResponse = { ...playerState, paused: true, }; rerender(); await act(async () => { vi.runOnlyPendingTimers(); }); expect(screen.getByTestId('Controls')).toHaveAttribute('data-playing', 'false'); }); it('should handle "offset" updates', async () => { const props = { autoPlay: true, uris: trackUris, }; const { rerender } = await setup(props); expect(screen.getByTestId('Controls')).toHaveAttribute('data-playing', 'true'); expect(fetchMock).toHaveBeenLastCalledWith( `https://api.spotify.com/v1/me/player/play?device_id=${deviceId}`, expect.objectContaining({ body: JSON.stringify({ uris: trackUris, offset: { position: 0 }, }), }), ); rerender(); expect(fetchMock).toHaveBeenLastCalledWith( `https://api.spotify.com/v1/me/player/play?device_id=${deviceId}`, expect.objectContaining({ body: JSON.stringify({ uris: trackUris, offset: { position: 1 }, }), }), ); }); }); describe('With "compact" layout', () => { it('should render properly', async () => { await setup({ layout: 'compact', styles: { bgColor: '#f04', color: '#fff' } }); expect(screen.getByTestId('Player')).toMatchSnapshot(); }); }); describe('With "getPlayer" prop', () => { it('should return the player', async () => { const mockGetPlayer = vi.fn(); await setup({ getPlayer: mockGetPlayer }); expect(mockGetPlayer).toHaveBeenCalledTimes(1); }); }); describe('With "components" prop', () => { it('should render the components', async () => { await setup({ components: { leftButton: , rightButton: , }, }); expect(screen.getByTestId('Controls')).toMatchSnapshot(); }); }); describe('With "preloadData" prop', () => { it('should have preloaded the first track of the album', async () => { await setup({ preloadData: true }); expect(fetchMock).toHaveBeenNthCalledWith( 2, 'https://api.spotify.com/v1/albums/7KvKuWUxxNPEU80c4i5AQk/tracks', expect.any(Object), ); expect(fetchMock).toHaveBeenNthCalledWith( 3, 'https://api.spotify.com/v1/tracks/6KUjwoHktuX3du8laPVfO8', expect.any(Object), ); }); }); }); ================================================ FILE: test/modules/__snapshots__/getters.spec.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`getLocale > should return a merged locale 1`] = ` { "currentDevice": "Selected device ", "devices": "Devices", "next": "Next", "otherDevices": "Select other device", "pause": "Pause", "play": "Play", "previous": "Previous", "removeTrack": "Remove from your favorites", "saveTrack": "Save to your favorites", "title": "{name} on SPOTIFY", "volume": "Volume", } `; exports[`getMergedStyles > should return a merged styles 1`] = ` { "activeColor": "#1cb954", "bgColor": "rgba(0, 0, 0, 0)", "color": "#333", "errorColor": "#ff0026", "height": 100, "loaderColor": "#ccc", "loaderSize": 32, "sliderColor": "#666", "sliderHandleBorderRadius": "50%", "sliderHandleColor": "#000", "sliderHeight": 4, "sliderTrackBorderRadius": 4, "sliderTrackColor": "#ccc", "trackArtistColor": "#666", "trackNameColor": "#333", } `; exports[`getPreloadData > should handle albums 1`] = ` { "artists": [ { "name": "Marvin Gaye", "uri": "spotify:artist:3koiLjNrgRTNbOwViDipeA", }, ], "durationMs": 151626, "id": "6KUjwoHktuX3du8laPVfO8", "image": "https://i.scdn.co/image/10b3bd8afaf3dfa1f302b8f58e059e9802144052", "name": "Main Theme From Trouble Man", "uri": "spotify:track:6KUjwoHktuX3du8laPVfO8", } `; exports[`getPreloadData > should handle artist 1`] = ` { "artists": [ { "name": "Marvin Gaye", "uri": "spotify:artist:3koiLjNrgRTNbOwViDipeA", }, ], "durationMs": 151626, "id": "6KUjwoHktuX3du8laPVfO8", "image": "https://i.scdn.co/image/10b3bd8afaf3dfa1f302b8f58e059e9802144052", "name": "Main Theme From Trouble Man", "uri": "spotify:track:6KUjwoHktuX3du8laPVfO8", } `; exports[`getPreloadData > should handle playlist 1`] = ` { "artists": [ { "name": "Marvin Gaye", "uri": "spotify:artist:3koiLjNrgRTNbOwViDipeA", }, ], "durationMs": 151626, "id": "6KUjwoHktuX3du8laPVfO8", "image": "https://i.scdn.co/image/10b3bd8afaf3dfa1f302b8f58e059e9802144052", "name": "Main Theme From Trouble Man", "uri": "spotify:track:6KUjwoHktuX3du8laPVfO8", } `; exports[`getPreloadData > should handle show 1`] = ` { "artists": [ { "name": "Syntax - Tasty Web Development Treats", "uri": "spotify:show:4kYCRYJ3yK5DQbP5tbfZby", }, ], "durationMs": 2645609, "id": "5cTJgzcfadfcfPzvJAwJk1", "image": "https://i.scdn.co/image/ab6765630000ba8ada4bfc6d17ba4b7f66e6012a", "name": "846: Talking EVs: Range Anxiety, Charging, and Tech", "uri": "spotify:episode:5cTJgzcfadfcfPzvJAwJk1", } `; exports[`getPreloadData > should handle track 1`] = ` { "artists": [ { "name": "Marvin Gaye", "uri": "spotify:artist:3koiLjNrgRTNbOwViDipeA", }, ], "durationMs": 151626, "id": "6KUjwoHktuX3du8laPVfO8", "image": "https://i.scdn.co/image/10b3bd8afaf3dfa1f302b8f58e059e9802144052", "name": "Main Theme From Trouble Man", "uri": "spotify:track:6KUjwoHktuX3du8laPVfO8", } `; ================================================ FILE: test/modules/getters.spec.ts ================================================ import { TRANSPARENT_COLOR } from '~/constants'; import { getBgColor, getLocale, getMergedStyles, getPreloadData, getSpotifyLink, getSpotifyLinkTitle, getSpotifyURIType, } from '~/modules/getters'; import { playerAlbumTracks, playerArtistTopTracks, playerPlaylistTracks, playerShow, playerTrack, } from '../fixtures/data'; describe('getBgColor', () => { it('should return the background color', () => { expect(getBgColor('#f04')).toBe('#f04'); }); it('should return the transparent color', () => { expect(getBgColor('transparent')).toBe(TRANSPARENT_COLOR); }); }); describe('getLocale', () => { it('should return a merged locale', () => { expect(getLocale({ currentDevice: 'Selected device ' })).toMatchSnapshot(); }); }); describe('getMergedStyles', () => { it('should return a merged styles', () => { expect(getMergedStyles({ bgColor: 'transparent', height: 100 })).toMatchSnapshot(); }); }); describe('getPreloadData', () => { beforeAll(() => { vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { vi.clearAllMocks(); }); afterAll(() => { vi.restoreAllMocks(); }); it('should handle albums', async () => { fetchMock.mockResponseOnce(JSON.stringify(playerAlbumTracks)); fetchMock.mockResponseOnce(JSON.stringify(playerTrack)); await expect( getPreloadData('token', 'spotify:album:7A0awCXkE1FtSU8B0qwOJQ', 0), ).resolves.toMatchSnapshot(); }); it('should handle artist', async () => { fetchMock.mockResponseOnce(JSON.stringify(playerArtistTopTracks)); await expect( getPreloadData('token', 'spotify:artist:7A0awCXkE1FtSU8B0qwOJQ', 0), ).resolves.toMatchSnapshot(); }); it('should handle playlist', async () => { fetchMock.mockResponseOnce(JSON.stringify(playerPlaylistTracks)); await expect( getPreloadData('token', 'spotify:playlist:7A0awCXkE1FtSU8B0qwOJQ', 0), ).resolves.toMatchSnapshot(); }); it('should handle show', async () => { fetchMock.mockResponseOnce(JSON.stringify(playerShow)); fetchMock.mockResponseOnce(JSON.stringify(playerShow.episodes)); await expect( getPreloadData('token', 'spotify:show:7A0awCXkE1FtSU8B0qwOJQ', 0), ).resolves.toMatchSnapshot(); }); it('should handle track', async () => { fetchMock.mockResponseOnce(JSON.stringify(playerTrack)); await expect( getPreloadData('token', 'spotify:track:7A0awCXkE1FtSU8B0qwOJQ', 0), ).resolves.toMatchSnapshot(); }); it('should handle invalid type', async () => { await expect( getPreloadData('token', 'spotify:episode:7A0awCXkE1FtSU8B0qwOJQ', 0), ).resolves.toBeNull(); expect(console.error).toHaveBeenCalledTimes(1); }); it('should handle API errors', async () => { fetchMock.mockRejectOnce(new Error('API error')); await expect( getPreloadData('token', 'spotify:track:7A0awCXkE1FtSU8B0qwOJQ', 0), ).resolves.toBeNull(); expect(console.error).toHaveBeenCalledTimes(1); }); }); describe('getSpotifyLink', () => { it('should return a Spotify link', () => { expect(getSpotifyLink('spotify:track:63DTXKZi7YdJ4tzGti1Dtr')).toBe( 'https://open.spotify.com/track/63DTXKZi7YdJ4tzGti1Dtr', ); }); }); describe('getSpotifyLinkTitle', () => { it('should return a formatted title', () => { expect(getSpotifyLinkTitle('Hommer', getLocale().title)).toBe('Hommer on SPOTIFY'); }); }); describe('getSpotifyURIType', () => { it.each([ ['spotify:album:51QBkcL7S3KYdXSSA0zM9R', 'album'], ['spotify:artist:7A0awCXkE1FtSU8B0qwOJQ', 'artist'], ['spotify:episode:6r8OOleI5xP7qCEipHvdyK', 'episode'], ['spotify:playlist:5kHMGRfZHORA4UrCbhYyad', 'playlist'], ['spotify:show:5huEzXsf133dhbh57Np2tg', 'show'], ['spotify:track:0gkVD2tr14wCfJhqhdE94L', 'track'], ['spotify:user:gilbarbara', 'user'], ['spotify', ''], ])('%s should return %s', (value, expected) => { expect(getSpotifyURIType(value)).toBe(expected); }); }); ================================================ FILE: test/modules/helpers.spec.ts ================================================ import { isNumber, loadSpotifyPlayer, millisecondsToTime, parseIds, parseVolume, round, validateURI, } from '~/modules/helpers'; describe('isNumber', () => { it('should return properly', () => { expect(isNumber(1)).toBeTrue(); expect(isNumber('1')).toBeFalse(); }); }); describe('loadSpotifyPlayer', () => { afterAll(() => { document.getElementById('spotify-player')?.remove(); }); it('should load the script', () => { loadSpotifyPlayer(); const scriptTag = document.getElementById('spotify-player') as HTMLScriptElement; expect(scriptTag.tagName).toBe('SCRIPT'); expect(scriptTag.src).toBe('https://sdk.scdn.co/spotify-player.js'); }); }); describe('millisecondsToTime', () => { it.each([ [0, '0:00'], [1200, '0:01'], [63000, '1:03'], [3610000, '01:00:10'], [7123490, '01:58:43'], ])('should convert %d to %s', (input, expected) => { expect(millisecondsToTime(input)).toBe(expected); }); }); describe('parseIds', () => { it('should return properly', () => { expect(parseIds('sek80pgtykoem9zr189zgyy9')).toEqual(['sek80pgtykoem9zr189zgyy9']); expect(parseIds(['sek80pgtykoem9zr189zgyy9'])).toEqual(['sek80pgtykoem9zr189zgyy9']); /* @ts-expect-error - missing parameter */ expect(parseIds()).toEqual([]); }); }); describe('parseVolume', () => { it.each([ [0.3, 0.3], [1, 1], ['100', 1], [3, 0.03], [20, 0.2], ])('should parse %d to %d', (input, expected) => { expect(parseVolume(input)).toBe(expected); }); }); describe('round', () => { it.each([ [10.1029, 1, 10.1], [34.0293, 2, 34.03], [79.0178, 3, 79.018], ])('should convert %d with %d digits to %d', (input, digits, expected) => { expect(round(input, digits)).toEqual(expected); }); }); describe('validateURI', () => { it.each([ ['spotify:album:51QBkcL7S3KYdXSSA0zM9R', true], ['spotify:artist:7A0awCXkE1FtSU8B0qwOJQ', true], ['spotify:episode:6r8OOleI5xP7qCEipHvdyK', false], ['spotify:playlist:5kHMGRfZHORA4UrCbhYyad', true], ['spotify:show:5huEzXsf133dhbh57Np2tg', true], ['spotify:track:0gkVD2tr14wCfJhqhdE94L', true], ['spotify:user:gilbarbara', false], ])('%s should return %s', (value, expected) => { expect(validateURI(value)).toBe(expected); }); }); ================================================ FILE: test/modules/spotify.spec.ts ================================================ import { checkTracksStatus, getAlbumTracks, getArtistTopTracks, getDevices, getPlaybackState, getPlaylistTracks, getQueue, getShow, getShowEpisodes, getTrack, next, pause, play, previous, removeTracks, repeat, saveTracks, seek, setDevice, setVolume, shuffle, } from '~/modules/spotify'; import { playbackState, playerAlbumTracks, playerArtistTopTracks, playerPlaylistTracks, playerShow, playerTrack, queue, } from '../fixtures/data'; const deviceId = 'df17372ghs982js892js'; const token = 'BQDoGCFtLXDAVgphhrRSPFHmhG9ZND3BLzSE5WVE-2qoe7_YZzRcVtZ6F7qEhzTih45GyxZLhp9b53A1YAPObAgV0MDvsbcQg-gZzlrIeQwwsWnz3uulVvPMhqssNP5HnE5SX0P0wTOOta1vneq2dL4Hvdko5WqvRivrEKWXCvJTPAFStfa5V5iLdCSglg'; const id = '2ViHeieFA3iPmsBya2NDFl'; const trackUri = `spotify:track:${id}`; const mockDevices = { devices: [ { id: deviceId, name: 'Test Player', type: 'Computer', }, ], }; describe('spotify', () => { beforeAll(() => { fetchMock.mockIf(/.*/, request => { const { url } = request; if (url.match(/contains\?ids=*/)) { return Promise.resolve({ body: JSON.stringify([false]), }); } else if (url.match(/albums/)) { return Promise.resolve({ body: JSON.stringify(playerAlbumTracks), }); } else if (url.match(/artists/)) { return Promise.resolve({ body: JSON.stringify(playerArtistTopTracks), }); } else if (url.match(/playlist/)) { return Promise.resolve({ body: JSON.stringify(playerPlaylistTracks), }); } else if (url.match(/shows.*0/)) { return Promise.resolve({ body: JSON.stringify(playerShow.episodes), }); } else if (url.match(/shows.*/)) { return Promise.resolve({ body: JSON.stringify(playerShow), }); } else if (url.match(/tracks/)) { return Promise.resolve({ body: JSON.stringify(playerTrack), }); } switch (url) { case 'https://api.spotify.com/v1/me/player/devices': { return Promise.resolve({ body: JSON.stringify(mockDevices), }); } case 'https://api.spotify.com/v1/me/player/queue': { return Promise.resolve({ body: JSON.stringify(queue), }); } case 'https://api.spotify.com/v1/me/player': { return Promise.resolve({ body: JSON.stringify(playbackState), }); } // No default } return Promise.resolve({ body: '', }); }); }); it('checkTracksStatus', async () => { await expect(checkTracksStatus(token, trackUri)).resolves.toEqual([false]); }); it('getAlbumTracks', async () => { await expect(getAlbumTracks(token, id)).resolves.toEqual(playerAlbumTracks); }); it('getArtistTopTracks', async () => { await expect(getArtistTopTracks(token, id)).resolves.toEqual(playerArtistTopTracks); }); it('getDevices', async () => { await expect(getDevices(token)).resolves.toEqual(mockDevices); }); it('getPlaybackState', async () => { await expect(getPlaybackState(token)).resolves.toEqual(playbackState); }); it('getQueue', async () => { await expect(getQueue(token)).resolves.toEqual(queue); }); it('getPlaylistTracks', async () => { await expect(getPlaylistTracks(token, id)).resolves.toEqual(playerPlaylistTracks); }); it('getShow', async () => { await expect(getShow(token, id)).resolves.toEqual(playerShow); }); it('getShowEpisodes', async () => { await expect(getShowEpisodes(token, id)).resolves.toEqual(playerShow.episodes); }); it('getTrack', async () => { await expect(getTrack(token, id)).resolves.toEqual(playerTrack); }); it('pause', async () => { await expect(pause(token)).resolves.toBeUndefined(); }); it('play', async () => { await expect(play(token, { deviceId })).resolves.toBeUndefined(); }); it('previous', async () => { await expect(previous(token)).resolves.toBeUndefined(); }); it('next', async () => { await expect(next(token)).resolves.toBeUndefined(); }); it('removeTracks', async () => { await expect(removeTracks(token, [trackUri])).resolves.toBeUndefined(); }); it('saveTracks', async () => { await expect(saveTracks(token, [trackUri])).resolves.toBeUndefined(); }); it('repeat', async () => { await expect(repeat(token, 'track')).resolves.toBeUndefined(); }); it('seek', async () => { await expect(seek(token, 1029)).resolves.toBeUndefined(); }); it('setDevice', async () => { await expect(setDevice(token, deviceId)).resolves.toBeUndefined(); }); it('setVolume', async () => { await expect(setVolume(token, 1)).resolves.toBeUndefined(); }); it('shuffle', async () => { await expect(shuffle(token, true)).resolves.toBeUndefined(); }); }); ================================================ FILE: test/tsconfig.json ================================================ { "extends": "../tsconfig", "compilerOptions": { "noUnusedLocals": false, "esModuleInterop": true, "module": "esnext" }, "include": ["**/*"] } ================================================ FILE: tsconfig.json ================================================ { "extends": "@gilbarbara/tsconfig", "compilerOptions": { "baseUrl": ".", "downlevelIteration": true, "noEmit": true, "paths": { "~/*": ["src/*"] }, "target": "ES2022" }, "include": ["src/**/*"] } ================================================ FILE: vitest.config.mts ================================================ import react from '@vitejs/plugin-react-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [tsconfigPaths(), react()], test: { include: ['test/**/*.spec.ts?(x)'], coverage: { all: true, include: ['src/**/*.ts?(x)'], exclude: [ 'src/components/icons/DevicesMobile.tsx', 'src/components/icons/DevicesSpeaker.tsx', ], reporter: ['text', 'lcov'], thresholds: { statements: 90, branches: 80, functions: 90, lines: 90, }, }, environment: 'jsdom', globals: true, setupFiles: ['./test/__setup__/vitest.setup.ts'], }, });