main a900bebf2c98 cached
79 files
287.5 KB
92.8k tokens
142 symbols
1 requests
Download .txt
Showing preview only (308K chars total). Download the full file or copy to clipboard to get everything.
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';

<SpotifyPlayer
  token="BQAI_7RWPJuqdZxS-I8XzhkUi9RKr8Q8UUNaJAHwWlpIq6..."
  uris={['spotify:artist:6HQYnRM4OzToCYPpVBInuU']}
/>;
```

### 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.

<details>
  <summary>Type Definition</summary>

```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;
}
```

</details>

**components** `CustomComponents`  
Custom components for the player.

<details>
  <summary>Type Definition</summary>

```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;
}
```

</details>

**getOAuthToken** `(callback: (token: string) => void) => Promise<void>`  
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._

<details>
  <summary>Example</summary>

```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 <SpotifyPlayer getOAuthToken={getOAuthToken} token={accessToken} uris={[]} />;
}
```

</details>

**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.

<details>
  <summary>Type Definition</summary>

```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'
}
```

</details>

**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\<boolean[]>**

**getAlbumTracks(token: string, id: string): Promise\<SpotifyApi.AlbumTracksResponse>**

**getArtistTopTracks(token: string, id: string): Promise\<SpotifyApi.ArtistsTopTracksResponse>**

**getDevices(token: string): Promise\<SpotifyApi.UserDevicesResponse>**

**getPlaybackState(token: string): Promise\<SpotifyApi.CurrentlyPlayingObject | null>**

**getPlaylistTracks(token: string, id: string): Promise\<SpotifyApi.PlaylistTrackResponse>**

**getQueue(token: string): Promise\<SpotifyApi.UsersQueueResponse>**

**getShow(token: string, id: string): Promise\<SpotifyApi.ShowObjectFull>**

**getShowEpisodes(token: string, id: string, offset = 0): Promise\<SpotifyApi.ShowEpisodesResponse>**

**getTrack(token: string, id: string): Promise\<SpotifyApi.TrackObjectFull>**

**pause(token: string, deviceId?: string): Promise\<void>**

**play(token: string, options: SpotifyPlayOptions): Promise\<void>**

```typescript
interface SpotifyPlayOptions {
  context_uri?: string;
  deviceId: string;
  offset?: number;
  uris?: string[];
}
```

**previous(token: string, deviceId?: string): Promise\<void>**

**next(token: string, deviceId?: string): Promise\<void>**

**removeTracks(token: string, tracks: string | string[]): Promise\<void>**

**repeat(token: string, state: 'context' | 'track' | 'off', deviceId?: string): Promise\<void>**

**saveTracks(token: string, tracks: string | string[]): Promise\<void>**

**seek(token: string, position: number, deviceId?: string): Promise\<void>**

**setDevice(token: string, deviceId: string, shouldPlay?: boolean): Promise\<void>**

**setVolume(token: string, volume: number, deviceId?: string): Promise\<void>**

**shuffle(token: string, state: boolean, deviceId?: string): Promise\<void>**

## 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
<SpotifyWebPlayer
  // ...
  styles={{
    activeColor: '#fff',
    bgColor: '#333',
    color: '#fff',
    loaderColor: '#fff',
    sliderColor: '#1cb954',
    trackArtistColor: '#ccc',
    trackNameColor: '#fff',
  }}
/>
```

![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
================================================
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, shrink-to-fit=no"
    />
    <meta name="theme-color" content="#000000" />
    <!--
      manifest.json provides metadata used when your web app is installed on a
      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
    -->
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.

      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
    <title>React Spotify Web Playback</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.

      You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.

      To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
  </body>
</html>


================================================
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<HTMLInputElement>(null);
  const isMounted = useRef(false);
  const playerRef = useRef<HTMLDivElement>(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<State>({
    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<HTMLButtonElement>) => {
      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<Type>).includes(type)) {
        const trackStyles = await request<StylesProps>(
          `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<HTMLDivElement> = 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<string, ReactNode> = {
    connect: (
      <Flex justify="center" maxWidth={320} mx="auto" width="100%">
        <Anchor href={getAuthorizeUrl()}>
          <Button size="lg">
            <Icon mr="sm" name="spotify" size={24} />
            <span>Connect</span>
          </Button>
        </Anchor>
      </Flex>
    ),
  };

  const getButtonStyle = (input: string) => {
    return URIs.join(',') === input ? 'primary.300' : 'primary';
  };

  if (error) {
    content.main = (
      <>
        <NonIdealState description={error} icon="close-o" mb="lg" title={null} />
        {content.connect}
      </>
    );
  } else if (code) {
    content.main = <Loader size={200} />;
  } else if (accessToken) {
    content.main = (
      <>
        <Box as="form" maxWidth={480} mx="auto" onSubmit={handleSubmitURIs} width="100%">
          <FormElementWrapper
            endContent={
              <Button
                shape="round"
                style={{ borderBottomLeftRadius: 0, borderTopLeftRadius: 0 }}
                type="submit"
              >
                <Icon name="check" size={24} />
              </Button>
            }
          >
            <Input
              ref={URIsInput}
              data-flex={1}
              defaultValue={URIs.join(',')}
              name="uris"
              placeholder="Enter a Spotify URI"
              suffixSpacing={48}
            />
          </FormElementWrapper>
        </Box>
        <Flex basis="50%" gap="md" justify="center" maxWidth={480} mt="xl" mx="auto" wrap="wrap">
          <Box textAlign="center">
            <Button
              bg={getButtonStyle(baseURIs.artist)}
              data-uris={baseURIs.artist}
              onClick={handleClickURIs}
              size="sm"
            >
              Play an Artist
            </Button>
            <Paragraph mt="xxxs">
              <Anchor
                color={getButtonStyle(baseURIs.artist)}
                external
                href="https://open.spotify.com/artist/7A0awCXkE1FtSU8B0qwOJQ?si=IP6f4hiVQ2Gk8XyepAhD0Q"
              >
                Jamie xx
              </Anchor>
            </Paragraph>
          </Box>
          <Box textAlign="center">
            <Button
              bg={getButtonStyle(baseURIs.album)}
              data-uris={baseURIs.album}
              onClick={handleClickURIs}
              size="sm"
            >
              Play an Album
            </Button>
            <Paragraph mt="xxxs">
              <Anchor
                color={getButtonStyle(baseURIs.album)}
                external
                href="https://open.spotify.com/album/4c7fP0tUymaZcrEFIeIeZc?si=GarHO227QGuyfteTlpMSzA"
              >
                Caribou - Honey
              </Anchor>
            </Paragraph>
          </Box>
          <Box textAlign="center">
            <Button
              bg={getButtonStyle(baseURIs.playlist)}
              data-uris={baseURIs.playlist}
              onClick={handleClickURIs}
              size="sm"
            >
              Play a Playlist
            </Button>
            <Paragraph mt="xxxs">
              <Anchor
                color={getButtonStyle(baseURIs.playlist)}
                external
                href="https://open.spotify.com/playlist/3h7lEfRkEdtVvGJTdTAudn?si=8294ef57c54d4fa8"
              >
                Nation
              </Anchor>
            </Paragraph>
          </Box>
          <Box textAlign="center">
            <Button
              bg={getButtonStyle(baseURIs.tracks.join(','))}
              data-uris={baseURIs.tracks.join(',')}
              onClick={handleClickURIs}
              size="sm"
            >
              Play some Tracks
            </Button>
            <Paragraph mt="xxxs">Dance Punk</Paragraph>
          </Box>
          <Box textAlign="center">
            <Button
              bg={getButtonStyle(baseURIs.show)}
              data-uris={baseURIs.show}
              onClick={handleClickURIs}
              size="sm"
            >
              Play a Show
            </Button>
            <Paragraph mt="xxxs">
              <Anchor
                color={getButtonStyle(baseURIs.show)}
                external
                href="https://open.spotify.com/show/4kYCRYJ3yK5DQbP5tbfZby"
              >
                Syntax
              </Anchor>
            </Paragraph>
          </Box>
        </Flex>
        <Flex gap="xl" justify="space-between" maxWidth={400} mt="xl" mx="auto" width="100%">
          <Box>
            <H4>Layout</H4>
            <ButtonGroup
              items={[{ label: 'responsive' }, { label: 'compact' }]}
              onClick={event => setState({ layout: event.currentTarget.textContent as Layout })}
              selected={layout}
              size="sm"
            />
          </Box>
          <Box>
            <H4>Props</H4>
            <Flex direction="column" gap="md" mx="auto" wrap="wrap">
              <Toggle
                checked={hideAttribution}
                label="Hide Attribution"
                name="hideAttribution"
                onToggle={() => setState({ hideAttribution: !hideAttribution })}
              />
              <Toggle
                checked={inlineVolume}
                label="Inline Volume"
                name="inlineVolume"
                onToggle={() => setState({ inlineVolume: !inlineVolume })}
              />
              <Toggle
                checked={transparent}
                label="Transparent"
                name="transparent"
                onToggle={value => setState({ transparent: value })}
              />
            </Flex>
          </Box>
        </Flex>
      </>
    );

    content.player = (
      <Player
        key={accessToken}
        ref={playerRef}
        draggable={layout === 'compact'}
        layout={layout}
        onDragStart={handlePlayerDrag}
      >
        {layout === 'compact' && (
          <ButtonUnstyled bg="white" opacity={0.8} radius="xxs">
            <Icon name="maximize-alt" size={20} />
          </ButtonUnstyled>
        )}
        <SpotifyWebPlayer
          callback={handleCallback}
          components={{
            leftButton: (
              <ShuffleButton disabled={!isActive} shuffle={shuffle} token={accessToken} />
            ),
            rightButton: <RepeatButton disabled={!isActive} repeat={repeat} token={accessToken} />,
          }}
          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}
        />
      </Player>
    );
  } else {
    content.main = content.connect;
  }

  return (
    <>
      <GlobalStyles hasToken={!!accessToken} />
      <Container fullScreen fullScreenOffset={accessToken ? 100 : 0} justify="center">
        <Spacer distribution="center" mb="xl">
          <H1 align="center" mb={0}>
            React Spotify Web Playback
          </H1>

          {accessToken && (
            <Button onClick={handleClickLogout} size="xs">
              <Icon name="sign-out" size={14} />
            </Button>
          )}
        </Spacer>

        {content.main}
        {content.player}
      </Container>
    </>
  );
}

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 (
    <Wrapper
      aria-label="View source on GitHub"
      className="github-corner"
      href="https://github.com/gilbarbara/react-spotify-web-playback"
      rel="noopener noreferrer"
      target="_blank"
    >
      <svg aria-hidden="true" height="80" viewBox="0 0 250 250" width="80">
        <path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z" />
        <path
          className="octo-arm"
          d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
          fill="currentColor"
        />
        <path
          className="octo-body"
          d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
          fill="currentColor"
        />
      </svg>
    </Wrapper>
  );
}

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 (
    <Global
      styles={css`
        body {
          background-color: #001638;
          color: ${theme.lightColor};
          box-sizing: border-box;
          font-family: sans-serif;
          margin: 0;
          min-height: 100vh;
          padding: 0 0 ${hasToken ? '100px' : 0};
        }

        .github-corner {
          position: fixed;
          top: 0;
          right: 0;
        }

        .github-corner svg {
          fill: ${theme.colors.primary};
          color: #fff;
        }

        .github-corner .octo-arm {
          transform-origin: 130px 106px;
        }

        .github-corner:hover .octo-arm {
          animation: octocat-wave 560ms ease-in-out;
        }

        @keyframes octocat-wave {
          0%,
          100% {
            transform: rotate(0);
          }
          20%,
          60% {
            transform: rotate(-25deg);
          }
          40%,
          80% {
            transform: rotate(10deg);
          }
        }

        @media (max-width: 500px) {
          .github-corner:hover .octo-arm {
            animation: none;
          }

          .github-corner .octo-arm {
            animation: octocat-wave 560ms ease-in-out;
          }
        }
      `}
    />
  );
}


================================================
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<ComponentProps<typeof ButtonUnstyled>, '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 (
    <ButtonUnstyled
      height={32}
      justify="center"
      onClick={handleClick}
      title={title}
      width={32}
      {...rest}
    >
      <FlexInline position="relative">
        <Icon name="repeat" size={24} title={null} />
        {repeat !== 'off' && (
          <span
            style={{
              fontSize: 9,
              fontWeight: 700,
              position: 'absolute',
              top: 5,
              left: '50%',
              transform: 'translateX(-50%)',
            }}
          >
            {repeat === 'track' ? '1' : 'all'}
          </span>
        )}
      </FlexInline>
    </ButtonUnstyled>
  );
}


================================================
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<ComponentProps<typeof ButtonUnstyled>, 'children'> & {
  shuffle: boolean;
  token: string;
}) {
  const handleClick = useCallback(async () => {
    await spotifyApi.shuffle(token, !shuffle);
  }, [shuffle, token]);

  return (
    <ButtonUnstyled
      height={32}
      justify="center"
      onClick={handleClick}
      title={`${shuffle ? 'Disable' : 'Enable'} repeat`}
      width={32}
      {...rest}
    >
      <FlexInline position="relative">
        <Icon name="shuffle" size={20} title={null} />
        {shuffle && (
          <span
            style={{
              fontSize: 8,
              fontWeight: 700,
              position: 'absolute',
              top: '50%',
              left: -2,
              transform: 'translateY(-50%)',
            }}
          >
            ⏺
          </span>
        )}
      </FlexInline>
    </ButtonUnstyled>
  );
}


================================================
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(
    <StrictMode>
      <ThemeProvider theme={theme}>
        <App />
      </ThemeProvider>
    </StrictMode>,
  );
}


================================================
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<SpotifyCredentials>;
}

export function setCredentials(credentials: SpotifyCredentials) {
  setCookie(COOKIE_NAME, JSON.stringify(credentials), { expires: MONTH * 6 });
}

export async function login(code: string) {
  return request<SpotifyCredentials>(`${API_URL}/spotifyGetUserCredentials`, {
    method: 'POST',
    body: { code, redirectUri: SPOTIFY.redirectUri },
  });
}

export function refreshCredentials(refreshToken: string) {
  return request<SpotifyCredentials>(`${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 <gilbarbara@gmail.com>",
  "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 (
    <Wrapper data-component-name="Actions" style={{ h: styles.height, layout }}>
      {children}
    </Wrapper>
  );
}

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<HTMLDivElement | null>(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 (
    <div ref={containerRef} {...rest}>
      {children}
    </div>
  );
}

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 (
    <Wrapper data-component-name="Controls" data-playing={isPlaying} style={{ layout }}>
      <Buttons style={{ c: color }}>
        {devices && <div className="rswp__devices">{devices}</div>}
        <div>{leftButton}</div>
        <div>
          <Button
            aria-label={locale.previous}
            className="ButtonRSWP"
            disabled={!isActive && !isExternalDevice}
            onClick={onClickPrevious}
            title={locale.previous}
            type="button"
          >
            <Previous />
          </Button>
        </div>
        <div>
          <Button
            aria-label={isPlaying ? locale.pause : locale.play}
            className="ButtonRSWP rswp__toggle"
            onClick={onClickTogglePlay}
            title={isPlaying ? locale.pause : locale.play}
            type="button"
          >
            {isPlaying ? <Pause /> : <Play />}
          </Button>
        </div>
        <div>
          <Button
            aria-label={locale.next}
            className="ButtonRSWP"
            disabled={!nextTracks.length && !isActive && !isExternalDevice}
            onClick={onClickNext}
            title={locale.next}
            type="button"
          >
            <Next />
          </Button>
        </div>
        <div>{rightButton}</div>
        {volume && <div className="rswp__volume">{volume}</div>}
      </Buttons>
      <Slider
        durationMs={durationMs}
        isMagnified={isMagnified}
        onChangeRange={onChangeRange}
        onToggleMagnify={onToggleMagnify}
        position={position}
        progressMs={progressMs}
        styles={styles}
      />
    </Wrapper>
  );
}

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 <DevicesSpeakerIcon />;
  }

  if (type.toLowerCase().includes('computer')) {
    return <DevicesComputerIcon />;
  }

  return <DevicesMobileIcon />;
}

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<HTMLElement>) => {
    const { dataset } = event.currentTarget;

    if (dataset.id) {
      onClickDevice(dataset.id);

      setOpen(false);
    }
  };

  const handleClickToggleList = useCallback(() => {
    setOpen(s => !s);
  }, []);

  const { currentDevice, otherDevices } = devices.reduce<DeviceList>(
    (acc, device) => {
      if (device.id === currentDeviceId) {
        acc.currentDevice = device;
      } else {
        acc.otherDevices.push(device);
      }

      return acc;
    },
    { currentDevice: null, otherDevices: [] },
  );

  let icon = <DevicesIcon />;

  if (deviceId && currentDevice && currentDevice.id !== deviceId) {
    icon = getDeviceIcon(currentDevice.type);
  }

  return (
    <ClickOutside isActive={isOpen} onClick={handleClickToggleList}>
      <Wrapper
        data-component-name="Devices"
        data-device-id={currentDeviceId}
        style={{
          c: color,
          layout,
          p: playerPosition,
        }}
      >
        {!!devices.length && (
          <>
            {isOpen && (
              <div>
                {currentDevice && (
                  <ListHeader>
                    <p>{locale.currentDevice}</p>
                    <p>
                      {getDeviceIcon(currentDevice.type)}
                      <span>{currentDevice.name}</span>
                    </p>
                  </ListHeader>
                )}
                {!!otherDevices.length && (
                  <>
                    <p>{locale.otherDevices}</p>
                    {otherDevices.map(device => (
                      <button
                        key={device.id}
                        aria-label={device.name}
                        className="ButtonRSWP"
                        data-id={device.id}
                        onClick={handleClickSetDevice}
                        type="button"
                      >
                        {getDeviceIcon(device.type)}
                        <span>{device.name}</span>
                      </button>
                    ))}
                  </>
                )}
                <span />
              </div>
            )}
            <button
              aria-label={locale.devices}
              className="ButtonRSWP"
              onClick={handleClickToggleList}
              title={locale.devices}
              type="button"
            >
              {icon}
            </button>
          </>
        )}
      </Wrapper>
    </ClickOutside>
  );
}


================================================
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 (
    <Wrapper data-component-name="ErrorMessage" style={{ bgColor, errorColor, h: height }}>
      {children}
    </Wrapper>
  );
}


================================================
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 = (
      <button
        aria-label={isSaved ? locale.removeTrack : locale.saveTrack}
        className={`ButtonRSWP${isSaved ? ' rswp__active' : ''}`}
        onClick={handleClickIcon}
        title={isSaved ? locale.removeTrack : locale.saveTrack}
        type="button"
      >
        {isSaved ? <Favorite /> : <FavoriteOutline />}
      </button>
    );
  }

  const content: Record<string, ReactNode> = {};
  const classes = [];

  if (isActive) {
    classes.push('rswp__active');
  }

  if (isCompactLayout) {
    content.image = <img alt={name} src={image} />;
  }

  if (!id) {
    return <div />;
  }

  return (
    <Wrapper
      className={classes.join(' ')}
      data-component-name="Info"
      style={{
        activeColor,
        c: color,
        h: height,
        layout,
        showSaveIcon,
      }}
    >
      {!hideCoverArt && (
        <a
          aria-label={title}
          href={getSpotifyLink(uri)}
          rel="noreferrer"
          target="_blank"
          title={title}
        >
          <img alt={name} src={image} />
        </a>
      )}
      <ContentWrapper
        style={{
          hideCoverArt,
          layout,
          showSaveIcon,
        }}
      >
        {!!name && (
          <Content
            style={{
              bgColor,
              layout,
              showSaveIcon,
              trackArtistColor,
              trackNameColor,
            }}
          >
            <div data-type="title-artist-wrapper">
              <div>
                <p>
                  <span>
                    <a
                      aria-label={title}
                      href={getSpotifyLink(uri)}
                      rel="noreferrer"
                      target="_blank"
                      title={title}
                    >
                      {name}
                    </a>
                  </span>
                </p>
                <p title={artists.map(d => d.name).join(', ')}>
                  {artists.map((artist, index) => {
                    const artistTitle = getSpotifyLinkTitle(artist.name, locale.title);

                    return (
                      <span key={artist.uri}>
                        {index ? ', ' : ''}
                        <a
                          aria-label={artistTitle}
                          href={getSpotifyLink(artist.uri)}
                          rel="noreferrer"
                          target="_blank"
                          title={artistTitle}
                        >
                          {artist.name}
                        </a>
                      </span>
                    );
                  })}
                </p>
              </div>
            </div>
            {favorite}
          </Content>
        )}
        {!hideAttribution && (
          <a
            aria-label="Play on Spotify"
            href={getSpotifyLink(uri)}
            rel="noreferrer"
            target="_blank"
          >
            <SpotifyLogo bgColor={bgColor} />
          </a>
        )}
      </ContentWrapper>
    </Wrapper>
  );
}

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 (
    <Wrapper data-component-name="Loader" style={{ h: height, loaderColor, loaderSize }}>
      <div />
    </Wrapper>
  );
}


================================================
FILE: src/components/Player.tsx
================================================
import { forwardRef } from 'react';

import { px } from '~/modules/styled';

import { ComponentsProps } from '~/types';

const Player = forwardRef<HTMLDivElement, ComponentsProps>((props, ref) => {
  const {
    children,
    styles: { bgColor, height },
    ...rest
  } = props;

  return (
    <div
      ref={ref}
      className="PlayerRSWP"
      data-component-name="Player"
      style={{ background: bgColor, minHeight: px(height) }}
      {...rest}
    >
      {children}
    </div>
  );
});

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 (
    <Wrapper
      data-component-name="Slider"
      data-position={position}
      onMouseEnter={onToggleMagnify}
      onMouseLeave={onToggleMagnify}
      style={{
        c: styles.color,
        sliderHeight: styles.sliderHeight,
      }}
    >
      <div className="rswp_progress">{millisecondsToTime(progressMs)}</div>
      <RangeSlider
        axis="x"
        className="slider"
        data-component-name="progress-bar"
        onChange={handleChangeRange}
        styles={{
          options: {
            thumbBorder: 0,
            thumbBorderRadius: styles.sliderHandleBorderRadius,
            thumbColor: styles.sliderHandleColor,
            thumbSize: isMagnified ? handleSize + 4 : handleSize,
            height: isMagnified ? styles.sliderHeight + 4 : styles.sliderHeight,
            padding: 0,
            rangeColor: styles.sliderColor,
            trackBorderRadius: styles.sliderTrackBorderRadius,
            trackColor: styles.sliderTrackColor,
          },
        }}
        x={position}
        xMax={100}
        xMin={0}
        xStep={0.1}
      />
      <div className="rswp_duration">{millisecondsToTime(durationMs)}</div>
    </Wrapper>
  );
}

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 (
    <svg height="1em" preserveAspectRatio="xMidYMid" viewBox="0 0 512 160" width="3.2em" {...rest}>
      <path
        d="M79.655 0C35.664 0 0 35.663 0 79.654c0 43.993 35.664 79.653 79.655 79.653 43.996 0 79.656-35.66 79.656-79.653 0-43.988-35.66-79.65-79.657-79.65L79.655 0Zm36.53 114.884a4.963 4.963 0 0 1-6.83 1.646c-18.702-11.424-42.246-14.011-69.973-7.676a4.967 4.967 0 0 1-5.944-3.738 4.958 4.958 0 0 1 3.734-5.945c30.343-6.933 56.37-3.948 77.367 8.884a4.965 4.965 0 0 1 1.645 6.83Zm9.75-21.689c-1.799 2.922-5.622 3.845-8.543 2.047-21.41-13.16-54.049-16.972-79.374-9.284a6.219 6.219 0 0 1-7.75-4.138 6.22 6.22 0 0 1 4.141-7.745c28.929-8.778 64.892-4.526 89.48 10.583 2.92 1.798 3.843 5.622 2.045 8.538Zm.836-22.585C101.1 55.362 58.742 53.96 34.231 61.4c-3.936 1.194-8.098-1.028-9.29-4.964a7.453 7.453 0 0 1 4.965-9.294c28.137-8.542 74.912-6.892 104.469 10.655a7.441 7.441 0 0 1 2.606 10.209c-2.092 3.54-6.677 4.707-10.206 2.605h-.004Zm89.944 2.922c-13.754-3.28-16.198-5.581-16.198-10.418 0-4.57 4.299-7.645 10.7-7.645 6.202 0 12.347 2.336 18.796 7.143.19.145.437.203.675.165a.888.888 0 0 0 .6-.367l6.715-9.466a.903.903 0 0 0-.171-1.225c-7.676-6.157-16.313-9.15-26.415-9.15-14.848 0-25.225 8.911-25.225 21.662 0 13.673 8.95 18.515 24.417 22.252 13.155 3.031 15.38 5.57 15.38 10.11 0 5.032-4.49 8.161-11.718 8.161-8.028 0-14.582-2.71-21.906-9.046a.932.932 0 0 0-.656-.218.89.89 0 0 0-.619.313l-7.533 8.96a.906.906 0 0 0 .086 1.256c8.522 7.61 19.004 11.624 30.323 11.624 16 0 26.339-8.742 26.339-22.277.028-11.421-6.81-17.746-23.561-21.821l-.029-.013Zm59.792-13.564c-6.934 0-12.622 2.732-17.321 8.33v-6.3c0-.498-.4-.903-.894-.903h-12.318a.899.899 0 0 0-.894.902v70.009c0 .494.4.903.894.903h12.318a.901.901 0 0 0 .894-.903v-22.097c4.699 5.26 10.387 7.838 17.32 7.838 12.89 0 25.94-9.92 25.94-28.886.019-18.97-13.032-28.894-25.93-28.894l-.01.001Zm11.614 28.893c0 9.653-5.945 16.397-14.468 16.397-8.418 0-14.772-7.048-14.772-16.397 0-9.35 6.354-16.397 14.772-16.397 8.38 0 14.468 6.893 14.468 16.396Zm47.759-28.893c-16.598 0-29.601 12.78-29.601 29.1 0 16.143 12.917 28.784 29.401 28.784 16.655 0 29.696-12.736 29.696-28.991 0-16.2-12.955-28.89-29.496-28.89v-.003Zm0 45.385c-8.827 0-15.485-7.096-15.485-16.497 0-9.444 6.43-16.298 15.285-16.298 8.884 0 15.58 7.093 15.58 16.504 0 9.443-6.468 16.291-15.38 16.291Zm64.937-44.258h-13.554V47.24c0-.497-.4-.902-.894-.902H374.05a.906.906 0 0 0-.904.902v13.855h-5.916a.899.899 0 0 0-.894.902v10.584a.9.9 0 0 0 .894.903h5.916v27.39c0 11.062 5.508 16.674 16.38 16.674 4.413 0 8.075-.914 11.528-2.873a.88.88 0 0 0 .457-.78v-10.083a.896.896 0 0 0-.428-.76.873.873 0 0 0-.876-.039c-2.368 1.19-4.66 1.741-7.229 1.741-3.947 0-5.716-1.798-5.716-5.812V73.49h13.554a.899.899 0 0 0 .894-.903V62.003a.873.873 0 0 0-.884-.903l-.01-.005Zm47.217.054v-1.702c0-5.006 1.921-7.238 6.22-7.238 2.57 0 4.633.51 6.945 1.28a.895.895 0 0 0 1.18-.858l-.001-10.377a.891.891 0 0 0-.637-.865c-2.435-.726-5.555-1.47-10.235-1.47-11.367 0-17.388 6.405-17.388 18.516v2.606h-5.916a.906.906 0 0 0-.904.902v10.638c0 .497.41.903.904.903h5.916v42.237c0 .504.41.904.904.904h12.308c.504 0 .904-.4.904-.904V73.487h11.5l17.616 42.234c-1.998 4.433-3.967 5.317-6.65 5.317-2.168 0-4.46-.646-6.79-1.93a.98.98 0 0 0-.714-.067.896.896 0 0 0-.533.485l-4.175 9.16a.9.9 0 0 0 .39 1.17c4.356 2.359 8.284 3.367 13.145 3.367 9.093 0 14.125-4.242 18.548-15.637l21.364-55.204a.88.88 0 0 0-.095-.838.878.878 0 0 0-.733-.392h-12.822a.901.901 0 0 0-.856.605l-13.136 37.509-14.382-37.534a.898.898 0 0 0-.837-.58h-21.04v-.003Zm-27.375-.054h-12.318a.907.907 0 0 0-.903.902v53.724c0 .504.409.904.903.904h12.318c.495 0 .904-.4.904-.904v-53.72a.9.9 0 0 0-.904-.903v-.003Zm-6.088-24.464c-4.88 0-8.836 3.95-8.836 8.828a8.835 8.835 0 0 0 8.836 8.836c4.88 0 8.827-3.954 8.827-8.836a8.83 8.83 0 0 0-8.827-8.828Z"
        fill={textColor(bgColor)}
      />
    </svg>
  );
}


================================================
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<number>();
  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 = <VolumeHigh />;

  if (volume === 0) {
    icon = <VolumeMute />;
  } else if (volume <= 0.4) {
    icon = <VolumeLow />;
  } else if (volume <= 0.7) {
    icon = <VolumeMid />;
  }

  if (isInline) {
    return (
      <WrapperInline data-component-name="Volume" data-value={volume} style={{ c: styles.color }}>
        <span>{icon}</span>
        <div>
          <RangeSlider
            axis="x"
            className="volume"
            data-component-name="volume-bar"
            onAfterEnd={handleAfterEnd}
            onChange={handleChangeSlider}
            styles={{
              options: {
                thumbBorder: 0,
                thumbBorderRadius: styles.sliderHandleBorderRadius,
                thumbColor: styles.sliderHandleColor,
                height: 4,
                padding: 0,
                rangeColor: styles.sliderColor,
                trackBorderRadius: styles.sliderTrackBorderRadius,
                trackColor: styles.sliderTrackColor,
              },
            }}
            x={volume * 100}
            xMax={100}
            xMin={0}
          />
        </div>
      </WrapperInline>
    );
  }

  return (
    <ClickOutside isActive={isOpen} onClick={handleClickToggleList}>
      <WrapperWithToggle
        data-component-name="Volume"
        data-value={volume}
        style={{ c: styles.color, layout, p: playerPosition }}
      >
        {isOpen && (
          <div>
            <RangeSlider
              axis="y"
              className="volume"
              data-component-name="volume-bar"
              onAfterEnd={handleAfterEnd}
              onChange={handleChangeSlider}
              styles={{
                options: {
                  padding: 0,
                  rangeColor: '#fff',
                  thumbBorder: 0,
                  thumbBorderRadius: 12,
                  thumbColor: '#fff',
                  thumbSize: 12,
                  trackColor: 'rgba(255, 255, 255, 0.5)',
                  width: 6,
                },
              }}
              y={volume * 100}
              yMax={100}
              yMin={0}
            />
            <span />
          </div>
        )}
        <button
          aria-label={locale.volume}
          className="ButtonRSWP"
          onClick={handleClickToggleList}
          title={locale.volume}
          type="button"
        >
          {icon}
        </button>
      </WrapperWithToggle>
    </ClickOutside>
  );
}


================================================
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 (
    <StyledWrapper data-component-name="Wrapper" style={{ h: styles.height, layout }}>
      {children}
    </StyledWrapper>
  );
}

export default memo(Wrapper);


================================================
FILE: src/components/icons/Devices.tsx
================================================
export default function DevicesIcon(props: any) {
  return (
    <svg height="1em" preserveAspectRatio="xMidYMid" viewBox="0 0 64 64" width="1em" {...props}>
      <path
        d="M57 4c3.864 0 7 3.136 7 7v42a7 7 0 0 1-7 7H31a7 7 0 0 1-7-7V11c0-3.864 3.136-7 7-7h26ZM16 54v6H8v-6h8Zm41-44H31a1 1 0 0 0-1 1v42a1 1 0 0 0 1 1h26a1 1 0 0 0 1-1V11a1 1 0 0 0-1-1ZM44 32a8 8 0 1 1 0 16 8 8 0 0 1 0-16ZM16 4v6H7a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h9v6H7a7 7 0 0 1-7-7V11c0-3.864 3.136-7 7-7h9Zm28 12a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z"
        fill="currentColor"
      />
    </svg>
  );
}


================================================
FILE: src/components/icons/DevicesComputer.tsx
================================================
export default function DevicesComputerIcon(props: any) {
  return (
    <svg height="1em" preserveAspectRatio="xMidYMid" viewBox="0 0 64 64" width="1em" {...props}>
      <path
        d="M7.226 10.323a7.228 7.228 0 0 1 7.226-7.226h35.096a7.228 7.228 0 0 1 7.226 7.226V37.16a7.226 7.226 0 0 1-7.226 7.226H14.452a7.226 7.226 0 0 1-7.226-7.226V10.323Zm7.226-1.033c-.57 0-1.033.462-1.033 1.033V37.16c0 .57.463 1.033 1.033 1.033h35.096c.57 0 1.033-.463 1.033-1.033V10.323c0-.57-.463-1.033-1.033-1.033H14.452ZM0 57.806a3.097 3.097 0 0 1 3.097-3.096h57.806a3.097 3.097 0 0 1 0 6.193H3.097A3.097 3.097 0 0 1 0 57.806Z"
        fill="currentColor"
      />
    </svg>
  );
}


================================================
FILE: src/components/icons/DevicesMobile.tsx
================================================
export default function DevicesMobileIcon(props: any) {
  return (
    <svg height="1em" preserveAspectRatio="xMidYMid" viewBox="0 0 64 64" width="1em" {...props}>
      <path
        d="M44.8 0a9.6 9.6 0 0 1 9.6 9.6v44.8a9.6 9.6 0 0 1-9.6 9.6H19.2a9.6 9.6 0 0 1-9.6-9.6V9.6A9.6 9.6 0 0 1 19.2 0h25.6Zm0 6.4H19.2A3.2 3.2 0 0 0 16 9.6v44.8a3.2 3.2 0 0 0 3.2 3.2h25.6a3.2 3.2 0 0 0 3.2-3.2V9.6a3.2 3.2 0 0 0-3.2-3.2ZM32 43.2a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z"
        fill="currentColor"
      />
    </svg>
  );
}


================================================
FILE: src/components/icons/DevicesSpeaker.tsx
================================================
export default function DevicesSpeakerIcon(props: any) {
  return (
    <svg height="1em" preserveAspectRatio="xMidYMid" viewBox="0 0 64 64" width="1em" {...props}>
      <path
        d="M45 4c3.864 0 7 3.136 7 7v42a7 7 0 0 1-7 7H19a7 7 0 0 1-7-7V11c0-3.864 3.136-7 7-7h26Zm0 6H19a1 1 0 0 0-1 1v42a1 1 0 0 0 1 1h26a1 1 0 0 0 1-1V11a1 1 0 0 0-1-1ZM32 32a8 8 0 1 1 0 16 8 8 0 0 1 0-16Zm0-16a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z"
        fill="currentColor"
      />
    </svg>
  );
}


================================================
FILE: src/components/icons/Favorite.tsx
================================================
export default function Favorite(props: any) {
  return (
    <svg height="1em" preserveAspectRatio="xMidYMid" viewBox="0 0 64 64" width="1em" {...props}>
      <path
        d="M63.673 16.52A17.676 17.676 0 0 0 49.197 2.563c-5.4-.861-10.891.852-14.844 4.63a3.43 3.43 0 0 1-4.672 0C22.956.689 12.305.62 5.498 7.039c-6.808 6.419-7.366 17.055-1.268 24.15l24.246 28.894a4.623 4.623 0 0 0 7.078 0L59.8 31.19a17.328 17.328 0 0 0 3.873-14.66v-.008Z"
        fill="currentColor"
      />
    </svg>
  );
}


================================================
FILE: src/components/icons/FavoriteOutline.tsx
================================================
export default function FavoriteOutline(props: any) {
  return (
    <svg height="1em" preserveAspectRatio="xMidYMid" viewBox="0 0 64 64" width="1em" {...props}>
      <path
        d="M5.944 7.206C13.271.3 24.723.34 31.999 7.3A18.924 18.924 0 0 1 48.02 2.32h.008a19.068 19.068 0 0 1 15.617 15.071v.013A18.759 18.759 0 0 1 59.47 33.26L37.573 59.353a7.288 7.288 0 0 1-8.642 1.916 7.276 7.276 0 0 1-2.498-1.912l-21.901-26.1c-6.55-7.671-5.93-19.131 1.408-26.051h.004Zm13.04 1.04a12.726 12.726 0 0 0-9.737 20.997l.021.02 21.905 26.105c.316.372.84.488 1.284.285.143-.066.27-.164.372-.285l21.934-26.137a12.565 12.565 0 0 0 2.808-10.625 12.875 12.875 0 0 0-10.534-10.17 12.714 12.714 0 0 0-10.785 3.37l-.029.029a6.198 6.198 0 0 1-8.444 0l-.037-.033a12.727 12.727 0 0 0-8.758-3.556Z"
        fill="currentColor"
      />
    </svg>
  );
}


================================================
FILE: src/components/icons/Next.tsx
================================================
export default function Next(props: any) {
  return (
    <svg height="1em" preserveAspectRatio="xMidYMid" viewBox="0 0 64 64" width="1em" {...props}>
      <path
        d="M53.486 0a3.2 3.2 0 0 0-3.2 3.2v23.543L4.8.489A3.2 3.2 0 0 0 0 3.255V60.74a3.2 3.2 0 0 0 4.8 2.774l45.486-26.262V60.8a3.2 3.2 0 0 0 3.2 3.2H60.8a3.2 3.2 0 0 0 3.2-3.2V3.2A3.2 3.2 0 0 0 60.8 0h-7.314Z"
        fill="currentColor"
      />
    </svg>
  );
}


================================================
FILE: src/components/icons/Pause.tsx
================================================
export default function Pause(props: any) {
  return (
    <svg height="1em" preserveAspectRatio="xMidYMid" viewBox="0 0 64 64" width="1em" {...props}>
      <path
        d="M32 0c17.673 0 32 14.327 32 32 0 17.673-14.327 32-32 32C14.327 64 0 49.673 0 32 0 14.327 14.327 0 32 0Zm-5.4 18h-5.2a1.4 1.4 0 0 0-1.4 1.4v25.2a1.4 1.4 0 0 0 1.4 1.4h5.2a1.4 1.4 0 0 0 1.4-1.4V19.4a1.4 1.4 0 0 0-1.4-1.4Zm16 0h-5.2a1.4 1.4 0 0 0-1.4 1.4v25.2a1.4 1.4 0 0 0 1.4 1.4h5.2a1.4 1.4 0 0 0 1.4-1.4V19.4a1.4 1.4 0 0 0-1.4-1.4Z"
        fill="currentColor"
      />
    </svg>
  );
}


================================================
FILE: src/components/icons/Play.tsx
================================================
export default function Play(props: any) {
  return (
    <svg height="1em" preserveAspectRatio="xMidYMid" viewBox="0 0 64 64" width="1em" {...props}>
      <path
        d="M32 0c17.673 0 32 14.327 32 32 0 17.673-14.327 32-32 32C14.327 64 0 49.673 0 32 0 14.327 14.327 0 32 0Zm-7.61 18.188c-.435.251-.702.715-.701 1.216v25.194a1.402 1.402 0 0 0 2.104 1.214L47.61 33.214a1.402 1.402 0 0 0 0-2.428L25.793 18.188c-.435-.25-.97-.25-1.404 0Z"
        fill="currentColor"
      />
    </svg>
  );
}


================================================
FILE: src/components/icons/Previous.tsx
================================================
export default function Previous(props: any) {
  return (
    <svg height="1em" preserveAspectRatio="xMidYMid" viewBox="0 0 64 64" width="1em" {...props}>
      <path
        d="M10.514 0a3.2 3.2 0 0 1 3.2 3.2v23.543L59.2.489A3.2 3.2 0 0 1 64 3.255V60.74a3.2 3.2 0 0 1-4.8 2.774L13.714 37.253V60.8a3.2 3.2 0 0 1-3.2 3.2H3.2A3.2 3.2 0 0 1 0 60.8V3.2A3.2 3.2 0 0 1 3.2 0h7.314Z"
        fill="currentColor"
      />
    </svg>
  );
}


================================================
FILE: src/components/icons/VolumeHigh.tsx
================================================
export default function VolumeHigh(props: any) {
  return (
    <svg
      data-component-name="VolumeHigh"
      height="1em"
      preserveAspectRatio="xMidYMid"
      viewBox="0 0 64 64"
      width="1em"
      {...props}
    >
      <path
        d="M37.963 3.402a2.989 2.989 0 0 1 1.5 2.596v52a3 3 0 0 1-4.5 2.6l-27.7-16C.32 40.572-2.06 31.688 1.943 24.73a14.556 14.556 0 0 1 5.32-5.328l27.7-16a3 3 0 0 1 3 0ZM45 9.542a23.008 23.008 0 0 1 0 44.912V48.25a17.008 17.008 0 0 0 0-32.508Zm-11.532 1.656-23.2 13.4a8.556 8.556 0 0 0 0 14.8l23.2 13.4v-41.6ZM45 22.238a11 11 0 0 1 0 19.52v-19.52Z"
        fill="currentColor"
      />
    </svg>
  );
}


================================================
FILE: src/components/icons/VolumeLow.tsx
================================================
export default function VolumeLow(props: any) {
  return (
    <svg
      data-component-name="VolumeLow"
      height="1em"
      preserveAspectRatio="xMidYMid"
      viewBox="0 0 64 64"
      width="1em"
      {...props}
    >
      <path
        d="M37.963 3.398a3 3 0 0 1 1.5 2.6v52a3 3 0 0 1-4.5 2.6l-27.7-16C.32 40.572-2.06 31.688 1.943 24.73a14.556 14.556 0 0 1 5.32-5.328l27.7-16a3 3 0 0 1 3 0v-.004Zm-27.696 21.2a8.556 8.556 0 0 0 0 14.8l23.2 13.4v-41.6l-23.2 13.4ZM45 41.758v-19.52a11 11 0 0 1 0 19.52Z"
        fill="currentColor"
      />
    </svg>
  );
}


================================================
FILE: src/components/icons/VolumeMid.tsx
================================================
export default function VolumeHigh(props: any) {
  return (
    <svg
      data-component-name="VolumeMid"
      height="1em"
      preserveAspectRatio="xMidYMid"
      viewBox="0 0 64 64"
      width="1em"
      {...props}
    >
      <path
        d="M37.963 3.398a3 3 0 0 1 1.5 2.6v52a3 3 0 0 1-4.5 2.6l-27.7-16C.32 40.572-2.06 31.688 1.943 24.73a14.556 14.556 0 0 1 5.32-5.328l27.7-16a3 3 0 0 1 3 0v-.004Zm-27.696 21.2a8.556 8.556 0 0 0 0 14.8l23.2 13.4v-41.6l-23.2 13.4ZM45 48.946a18.008 18.008 0 0 0 0-33.896v6.6a11.996 11.996 0 0 1 0 20.7v6.596Z"
        fill="currentColor"
      />
    </svg>
  );
}


================================================
FILE: src/components/icons/VolumeMute.tsx
================================================
export default function VolumeMute(props: any) {
  return (
    <svg
      data-component-name="VolumeMute"
      height="1em"
      preserveAspectRatio="xMidYMid"
      viewBox="0 0 64 64"
      width="1em"
      {...props}
    >
      <path
        d="M34.963 3.402a3 3 0 0 1 4.5 2.6v7.624a19.03 19.03 0 0 0-6 2.776v-5.2l-23.2 13.4a8.57 8.57 0 0 0-3.12 3.128 8.564 8.564 0 0 0 3.124 11.68l23.196 13.392v-5.2a18.92 18.92 0 0 0 6 2.776v7.624a3 3 0 0 1-4.5 2.596l-27.7-16a14.556 14.556 0 0 1-5.32-5.328C-2.06 32.313.32 23.428 7.263 19.402l27.7-16Zm17.354 17.6a3 3 0 0 1 2.122 5.12l-5.88 5.88 5.876 5.88a3 3 0 0 1-4.24 4.24l-5.88-5.88-5.88 5.88a3 3 0 1 1-4.385-4.095l6.025-6.025-5.876-5.88a3 3 0 0 1 4.236-4.24l5.88 5.88 5.88-5.88a3 3 0 0 1 2.122-.88Z"
        fill="currentColor"
      />
    </svg>
  );
}


================================================
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<Props, State> {
  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<HTMLDivElement>();
  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<string, ReactNode> = {
      main: <Loader styles={this.styles} />,
    };

    if (isReady) {
      if (!output.info) {
        output.info = (
          <Info
            hideAttribution={hideAttribution}
            hideCoverArt={hideCoverArt}
            isActive={isActive}
            layout={layout}
            locale={this.locale}
            onFavoriteStatusChange={this.handleFavoriteStatusChange}
            showSaveIcon={showSaveIcon!}
            styles={this.styles}
            token={this.token}
            track={track}
            updateSavedStatus={updateSavedStatus}
          />
        );
      }

      output.devices = (
        <Devices
          currentDeviceId={currentDeviceId}
          deviceId={deviceId}
          devices={devices}
          layout={layout}
          locale={this.locale}
          onClickDevice={this.handleClickDevice}
          open={isUnsupported && !deviceId}
          playerPosition={playerPosition}
          styles={this.styles}
        />
      );

      output.volume = currentDeviceId ? (
        <Volume
          inlineVolume={inlineVolume}
          layout={layout}
          locale={this.locale}
          playerPosition={playerPosition}
          setVolume={this.setVolume}
          styles={this.styles}
          volume={volume}
        />
      ) : null;

      if (this.renderInlineActions) {
        output.actions = (
          <Actions layout={layout} styles={this.styles}>
            {output.devices}
            {output.volume}
          </Actions>
        );
      }

      output.controls = (
        <Controls
          components={components}
          devices={this.renderInlineActions ? null : output.devices}
          durationMs={track.durationMs}
          isActive={isActive}
          isExternalDevice={this.isExternalPlayer}
          isMagnified={isMagnified}
          isPlaying={isPlaying}
          layout={layout}
          locale={this.locale}
          nextTracks={nextTracks}
          onChangeRange={this.handleChangeRange}
          onClickNext={this.handleClickNext}
          onClickPrevious={this.handleClickPrevious}
          onClickTogglePlay={this.handleClickTogglePlay}
          onToggleMagnify={this.handleToggleMagnify}
          position={position}
          progressMs={progressMs}
          styles={this.styles}
          volume={this.renderInlineActions ? null : output.volume}
        />
      );

      output.main = (
        <Wrapper layout={layout} styles={this.styles}>
          {output.info}
          {output.controls}
          {output.actions}
        </Wrapper>
      );
    } else if (output.info) {
      output.main = output.info;
    }

    if (status === STATUS.ERROR) {
      output.main = <ErrorMessage styles={this.styles}>{error}</ErrorMessage>;
    }

    return (
      <Player ref={this.ref} data-ready={isReady} styles={this.styles}>
        {output.main}
      </Player>
    );
  }
}

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>): 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<SpotifyTrack | null> {
  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<any> {
  return new Promise<void>((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<boolean>(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<T>(value: T): T {
  const ref: any = useRef<T>();

  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<boolean[]> {
  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<SpotifyApi.AlbumTracksResponse> {
  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<SpotifyApi.ArtistsTopTracksResponse> {
  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<SpotifyApi.UserDevicesResponse> {
  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<SpotifyApi.CurrentlyPlayingObject | null> {
  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<SpotifyApi.PlaylistTrackResponse> {
  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<SpotifyApi.UsersQueueResponse> {
  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<SpotifyApi.ShowObjectFull> {
  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<SpotifyApi.ShowEpisodesResponse> {
  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<SpotifyApi.TrackObjectFull> {
  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<void> {
  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<void> {
  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<void> {
  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<void> {
  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<void> {
  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<void> {
  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<void> {
  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<void> {
  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<void> {
  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<void> {
  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<void> {
  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<Partial<StyledProps>>;
}

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<StylesOptions>;

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<void>;
  /**
   * 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<Locale>;
  /**
   * 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<string, any>;
}

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<SpotifyArtist, 'name' | 'uri'>[];
  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`] = `
<div
  class="PlayerRSWP"
  data-component-name="Player"
  data-ready="true"
  style="background: rgb(255, 255, 255); min-height: 80px;"
>
  <div
    class=" _WrapperRSWP __1g3et1y"
    data-component-name="Wrapper"
  >
    <div
      class="rswp__active _InfoRSWP __14asrht"
      data-component-name="Info"
    >
      <a
        aria-label="Main Theme From Trouble Man on SPOTIFY"
        href="https://open.spotify.com/track/6KUjwoHktuX3du8laPVfO8"
        rel="noreferrer"
        target="_blank"
        title="Main Theme From Trouble Man on SPOTIFY"
      >
        <img
          alt="Main Theme From Trouble Man"
          src="https://i.scdn.co/image/10b3bd8afaf3dfa1f302b8f58e059e9802144052"
        />
      </a>
      <div
        class=" _ContentWrapperRSWP __1c53jt9"
      >
        <div
          class=" _ContentRSWP __1of0817"
        >
          <div
            data-type="title-artist-wrapper"
          >
            <div>
              <p>
                <span>
                  <a
                    aria-label="Main Theme From Trouble Man on SPOTIFY"
                    href="https://open.spotify.com/track/6KUjwoHktuX3du8laPVfO8"
                    rel="noreferrer"
                    target="_blank"
                    title="Main Theme From Trouble Man on SPOTIFY"
                  >
                    Main Theme From Trouble Man
                  </a>
                </span>
              </p>
              <p
                title="Marvin Gaye"
              >
                <span>
                  <a
                    aria-label="Marvin Gaye on SPOTIFY"
                    href="https://open.spotify.com/artist/3koiLjNrgRTNbOwViDipeA"
                    rel="noreferrer"
                    target="_blank"
                    title="Marvin Gaye on SPOTIFY"
                  >
                    Marvin Gaye
                  </a>
                </span>
              </p>
            </div>
          </div>
        </div>
        <a
          aria-label="Play on Spotify"
          href="https://open.spotify.com/track/6KUjwoHktuX3du8laPVfO8"
          rel="noreferrer"
          target="_blank"
        >
          <svg
            height="1em"
            preserveAspectRatio="xMidYMid"
            viewBox="0 0 512 160"
            width="3.2em"
          >
            <path
              d="M79.655 0C35.664 0 0 35.663 0 79.654c0 43.993 35.664 79.653 79.655 79.653 43.996 0 79.656-35.66 79.656-79.653 0-43.988-35.66-79.65-79.657-79.65L79.655 0Zm36.53 114.884a4.963 4.963 0 0 1-6.83 1.646c-18.702-11.424-42.246-14.011-69.973-7.676a4.967 4.967 0 0 1-5.944-3.738 4.958 4.958 0 0 1 3.734-5.945c30.343-6.933 56.37-3.948 77.367 8.884a4.965 4.965 0 0 1 1.645 6.83Zm9.75-21.689c-1.799 2.922-5.622 3.845-8.543 2.047-21.41-13.16-54.049-16.972-79.374-9.284a6.219 6.219 0 0 1-7.75-4.138 6.22 6.22 0 0 1 4.141-7.745c28.929-8.778 64.892-4.526 89.48 10.583 2.92 1.798 3.843 5.622 2.045 8.538Zm.836-22.585C101.1 55.362 58.742 53.96 34.231 61.4c-3.936 1.194-8.098-1.028-9.29-4.964a7.453 7.453 0 0 1 4.965-9.294c28.137-8.542 74.912-6.892 104.469 10.655a7.441 7.441 0 0 1 2.606 10.209c-2.092 3.54-6.677 4.707-10.206 2.605h-.004Zm89.944 2.922c-13.754-3.28-16.198-5.581-16.198-10.418 0-4.57 4.299-7.645 10.7-7.645 6.202 0 12.347 2.336 18.796 7.143.19.145.437.203.675.165a.888.888 0 0 0 .6-.367l6.715-9.466a.903.903 0 0 0-.171-1.225c-7.676-6.157-16.313-9.15-26.415-9.15-14.848 0-25.225 8.911-25.225 21.662 0 13.673 8.95 18.515 24.417 22.252 13.155 3.031 15.38 5.57 15.38 10.11 0 5.032-4.49 8.161-11.718 8.161-8.028 0-14.582-2.71-21.906-9.046a.932.932 0 0 0-.656-.218.89.89 0 0 0-.619.313l-7.533 8.96a.906.906 0 0 0 .086 1.256c8.522 7.61 19.004 11.624 30.323 11.624 16 0 26.339-8.742 26.339-22.277.028-11.421-6.81-17.746-23.561-21.821l-.029-.013Zm59.792-13.564c-6.934 0-12.622 2.732-17.321 8.33v-6.3c0-.498-.4-.903-.894-.903h-12.318a.899.899 0 0 0-.894.902v70.009c0 .494.4.903.894.903h12.318a.901.901 0 0 0 .894-.903v-22.097c4.699 5.26 10.387 7.838 17.32 7.838 12.89 0 25.94-9.92 25.94-28.886.019-18.97-13.032-28.894-25.93-28.894l-.01.001Zm11.614 28.893c0 9.653-5.945 16.397-14.468 16.397-8.418 0-14.772-7.048-14.772-16.397 0-9.35 6.354-16.397 14.772-16.397 8.38 0 14.468 6.893 14.468 16.396Zm47.759-28.893c-16.598 0-29.601 12.78-29.601 29.1 0 16.143 12.917 28.784 29.401 28.784 16.655 0 29.696-12.736 29.696-28.991 0-16.2-12.955-28.89-29.496-28.89v-.003Zm0 45.385c-8.827 0-15.485-7.096-15.485-16.497 0-9.444 6.43-16.298 15.285-16.298 8.884 0 15.58 7.093 15.58 16.504 0 9.443-6.468 16.291-15.38 16.291Zm64.937-44.258h-13.554V47.24c0-.497-.4-.902-.894-.902H374.05a.906.906 0 0 0-.904.902v13.855h-5.916a.899.899 0 0 0-.894.902v10.584a.9.9 0 0 0 .894.903h5.916v27.39c0 11.062 5.508 16.674 16.38 16.674 4.413 0 8.075-.914 11.528-2.873a.88.88 0 0 0 .457-.78v-10.083a.896.896 0 0 0-.428-.76.873.873 0 0 0-.876-.039c-2.368 1.19-4.66 1.741-7.229 1.741-3.947 0-5.716-1.798-5.716-5.812V73.49h13.554a.899.899 0 0 0 .894-.903V62.003a.873.873 0 0 0-.884-.903l-.01-.005Zm47.217.054v-1.702c0-5.006 1.921-7.238 6.22-7.238 2.57 0 4.633.51 6.945 1.28a.895.895 0 0 0 1.18-.858l-.001-10.377a.891.891 0 0 0-.637-.865c-2.435-.726-5.555-1.47-10.235-1.47-11.367 0-17.388 6.405-17.388 18.516v2.606h-5.916a.906.906 0 0 0-.904.902v10.638c0 .497.41.903.904.903h5.916v42.237c0 .504.41.904.904.904h12.308c.504 0 .904-.4.904-.904V73.487h11.5l17.616 42.234c-1.998 4.433-3.967 5.317-6.65 5.317-2.168 0-4.46-.646-6.79-1.93a.98.98 0 0 0-.714-.067.896.896 0 0 0-.533.485l-4.175 9.16a.9.9 0 0 0 .39 1.17c4.356 2.359 8.284 3.367 13.145 3.367 9.093 0 14.125-4.242 18.548-15.637l21.364-55.204a.88.88 0 0 0-.095-.838.878.878 0 0 0-.733-.392h-12.822a.901.901 0 0 0-.856.605l-13.136 37.509-14.382-37.534a.898.898 0 0 0-.837-.58h-21.04v-.003Zm-27.375-.054h-12.318a.907.907 0 0 0-.903.902v53.724c0 .504.409.904.903.904h12.318c.495 0 .904-.4.904-.904v-53.72a.9.9 0 0 0-.904-.903v-.003Zm-6.088-24.464c-4.88 0-8.836 3.95-8.836 8.828a8.835 8.835 0 0 0 8.836 8.836c4.88 0 8.827-3.954 8.827-8.836a8.83 8.83 0 0 0-8.827-8.828Z"
              fill="#000000"
            />
          </svg>
        </a>
      </div>
    </div>
    <div
      class=" _ControlsRSWP __11aa6ep"
      data-component-name="Controls"
      data-playing="true"
    >
      <div
        class=" _ControlsButtonsRSWP __a3qe0k"
      >
        <div
          class="rswp__devices"
        >
          <div>
            <div
              class=" _DevicesRSWP __n5xkwq"
              data-component-name="Devices"
              data-device-id="19ks98hfbxc53vh34jd"
            >
              <button
                aria-label="Devices"
                class="ButtonRSWP"
                title="Devices"
                type="button"
              >
                <svg
                  height="1em"
                  preserveAspectRatio="xMidYMid"
                  viewBox="0 0 64 64"
                  width="1em"
                >
                  <path
                    d="M57 4c3.864 0 7 3.136 7 7v42a7 7 0 0 1-7 7H31a7 7 0 0 1-7-7V11c0-3.864 3.136-7 7-7h26ZM16 54v6H8v-6h8Zm41-44H31a1 1 0 0 0-1 1v42a1 1 0 0 0 1 1h26a1 1 0 0 0 1-1V11a1 1 0 0 0-1-1ZM44 32a8 8 0 1 1 0 16 8 8 0 0 1 0-16ZM16 4v6H7a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h9v6H7a7 7 0 0 1-7-7V11c0-3.864 3.136-7 7-7h9Zm28 12a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z"
                    fill="currentColor"
                  />
                </svg>
              </button>
            </div>
          </div>
        </div>
        <div />
        <div>
          <button
            aria-label="Previous"
            class="ButtonRSWP _ControlsButtonRSWP __3hmsj"
            title="Previous"
            type="button"
          >
            <svg
              height="1em"
              preserveAspectRatio="xMidYMid"
              viewBox="0 0 64 64"
              width="1em"
            >
              <path
                d="M10.514 0a3.2 3.2 0 0 1 3.2 3.2v23.543L59.2.489A3.2 3.2 0 0 1 64 3.255V60.74a3.2 3.2 0 0 1-4.8 2.774L13.714 37.253V60.8a3.2 3.2 0 0 1-3.2 3.2H3.2A3.2 3.2 0 0 1 0 60.8V3.2A3.2 3.2 0 0 1 3.2 0h7.314Z"
                fill="currentColor"
              />
            </svg>
          </button>
        </div>
        <div>
          <button
            aria-label="Pause"
            class="ButtonRSWP rswp__toggle _ControlsButtonRSWP __3hmsj"
            title="Pause"
            type="button"
          >
            <svg
              height="1em"
              preserveAspectRatio="xMidYMid"
              viewBox="0 0 64 64"
              width="1em"
            >
              <path
                d="M32 0c17.673 0 32 14.327 32 32 0 17.673-14.327 32-32 32C14.327 64 0 49.673 0 32 0 14.327 14.327 0 32 0Zm-5.4 18h-5.2a1.4 1.4 0 0 0-1.4 1.4v25.2a1.4 1.4 0 0 0 1.4 1.4h5.2a1.4 1.4 0 0 0 1.4-1.4V19.4a1.4 1.4 0 0 0-1.4-1.4Zm16 0h-5.2a1.4 1.4 0 0 0-1.4 1.4v25.2a1.4 1.4 0 0 0 1.4 1.4h5.2a1.4 1.4 0 0 0 1.4-1.4V19.4a1.4 1.4 0 0 0-1.4-1.4Z"
                fill="currentColor"
              />
            </svg>
          </button>
        </div>
        <div>
          <button
            aria-label="Next"
            class="ButtonRSWP _ControlsButtonRSWP __3hmsj"
            title="Next"
            type="button"
          >
            <svg
              height="1em"
              preserveAspectRatio="xMidYMid"
              viewBox="0 0 64 64"
              width="1em"
            >
              <path
                d="M53.486 0a3.2 3.2 0 0 0-3.2 3.2v23.543L4.8.489A3.2 3.2 0 0 0 0 3.255V60.74a3.2 3.2 0 0 0 4.8 2.774l45.486-26.262V60.8a3.2 3.2 0 0 0 3.2 3.2H60.8a3.2 3.2 0 0 0 3.2-3.2V3.2A3.2 3.2 0 0 0 60.8 0h-7.314Z"
                fill="currentColor"
              />
            </svg>
          </button>
        </div>
        <div />
        <div
          class="rswp__volume"
        >
          <div
            class=" _VolumeInlineRSWP __a3qe0k"
            data-component-name="Volume"
            data-value="1"
          >
            <span>
              <svg
                data-component-name="VolumeHigh"
                height="1em"
                preserveAspectRatio="xMidYMid"
                viewBox="0 0 64 64"
                width="1em"
              >
                <path
                  d="M37.963 3.402a2.989 2.989 0 0 1 1.5 2.596v52a3 3 0 0 1-4.5 2.6l-27.7-16C.32 40.572-2.06 31.688 1.943 24.73a14.556 14.556 0 0 1 5.32-5.328l27.7-16a3 3 0 0 1 3 0ZM45 9.542a23.008 23.008 0 0 1 0 44.912V48.25a17.008 17.008 0 0 0 0-32.508Zm-11.532 1.656-23.2 13.4a8.556 8.556 0 0 0 0 14.8l23.2 13.4v-41.6ZM45 22.238a11 11 0 0 1 0 19.52v-19.52Z"
                  fill="currentColor"
                />
              </svg>
            </span>
            <div>
              <div
                class="volume"
                data-component-name="volume-bar"
                style="box-sizing: border-box; display: inline-block; padding: 0px; transition: height 0.4s, width 0.4s; height: 4px; width: 100%;"
              >
                <div
                  class="volume__track"
                  role="presentation"
                  style="background-color: rgb(204, 204, 204); border-radius: 4px; box-sizing: border-box; height: 4px; position: relative; width: 100%;"
                >
                  <div
                    class="volume__range"
                    style="width: 100%; background-color: rgb(102, 102, 102); border-radius: 4px; position: absolute; height: 100%; top: 0px;"
                  />
                  <div
                    role="presentation"
                    style="box-sizing: border-box; height: 4px; position: absolute; transition: height 0.4s, width 0.4s; width: 20px; left: 100%; bottom: 0%;"
                  >
                    <span
                      aria-label="slider handle"
                      aria-orientation="horizontal"
                      aria-valuemax="100"
                      aria-valuemin="0"
                      aria-valuenow="100"
                      class="volume__thumb"
                      role="slider"
                      style="background-color: rgb(0, 0, 0); border: 0px; border-radius: 50%; box-sizing: border-box; display: block; position: absolute; transition: height 0.4s, width 0.4s; height: 10px; left: -5px; top: -3px; width: 10px;"
                      tabindex="0"
                    />
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
      <div
        class=" _SliderRSWP __q14q70"
        data-component-name="Slider"
        data-position="0"
      >
        <div
          class="rswp_progress"
        >
          0:00
        </div>
        <div
          class="slider"
          data-component-name="progress-bar"
          style="box-sizing: border-box; display: inline-block; padding: 0px; transition: height 0.4s, width 0.4s; height: 4px; width: 100%;"
        >
          <div
            class="slider__track"
            role="presentation"
            style="background-color: rgb(204, 204, 204); border-radius: 4px; box-sizing: border-box; height: 4px; position: relative; width: 100%;"
          >
            <div
              class="slider__range"
              style="width: 0%; background-color: rgb(102, 102, 102); border-radius: 4px; position: absolute; height: 100%; top: 0px;"
            />
            <div
              role="presentation"
              style="box-sizing: border-box; height: 4px; position: absolute; transition: height 0.4s, width 0.4s; width: 20px; left: 0%; bottom: 0%;"
            >
              <span
                aria-label="slider handle"
                aria-orientation="horizontal"
                aria-valuemax="100"
                aria-valuemin="0"
                aria-valuenow="0"
                class="slider__thumb"
                role="slider"
                style="background-color: rgb(0, 0, 0); border: 0px; border-radius: 50%; box-sizing: border-box; display: block; position: absolute; transition: height 0.4s, width 0.4s; height: 10px; left: -5px; top: -3px; width: 10px;"
                tabindex="0"
              />
            </div>
          </div>
        </div>
        <div
          class="rswp_duration"
        >
          2:31
        </div>
      </div>
    </div>
  </div>
</div>
`;

exports[`SpotifyWebPlayer > Device listeners > should handle \`ready\` 1`] = `
<div
  class="PlayerRSWP"
  data-component-name="Player"
  data-ready="true"
  style="background: rgb(255, 255, 255); min-height: 80px;"
>
  <div
    class=" _WrapperRSWP __1g3et1y"
    data-component-name="Wrapper"
  >
    <div />
    <div
      class=" _ControlsRSWP __11aa6ep"
      data-component-name="Controls"
      data-playing="false"
    >
      <div
        class=" _ControlsButtonsRSWP __a3qe0k"
      >
        <div
          class="rswp__devices"
        >
          <div>
            <div
              class=" _DevicesRSWP __n5xkwq"
              data-component-name="Devices"
              data-device-id="19ks98hfbxc53vh34jd"
            >
              <button
                aria-label="Devices"
                class="ButtonRSWP"
                title="Devices"
                type="button"
              >
                <svg
                  height="1em"
                  preserveAspectRatio="xMidYMid"
                  viewBox="0 0 64 64"
                  width="1em"
                >
                  <path
                    d="M57 4c3.864 0 7 3.136 7 7v42a7 7 0 0 1-7 7H31a7 7 0 0 1-7-7V11c0-3.864 3.136-7 7-7h26ZM16 54v6H8v-6h8Zm41-44H31a1 1 0 0 0-1 1v42a1 1 0 0 0 1 1h26a1 1 0 0 0 1-1V11a1 1 0 0 0-1-1ZM44 32a8 8 0 1 1 0 16 8 8 0 0 1 0-16ZM16 4v6H7a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h9v6H7a7 7 0 0 1-7-7V11c0-3.864 3.136-7 7-7h9Zm28 12a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z"
                    fill="currentColor"
                  />
                </svg>
              </button>
            </div>
          </div>
        </div>
        <div />
        <div>
          <button
            aria-label="Previous"
            class="ButtonRSWP _ControlsButtonRSWP __3hmsj"
            disabled=""
            title="Previous"
            type="button"
          >
            <svg
              height="1em"
              preserveAspectRatio="xMidYMid"
              viewBox="0 0 64 64"
              width="1em"
            >
              <path
                d="M10.514 0a3.2 3.2 0 0 1 3.2 3.2v23.543L59.2.489A3.2 3.2 0 0 1 64 3.255V60.74a3.2 3.2 0 0 1-4.8 2.774L13.714 37.253V60.8a3.2 3.2 0 0 1-3.2 3.2H3.2A3.2 3.2 0 0 1 0 60.8V3.2A3.2 3.2 0 0 1 3.2 0h7.314Z"
                fill="currentColor"
              />
            </svg>
          </button>
        </div>
        <div>
          <button
            aria-label="Play"
            class="ButtonRSWP rswp__toggle _ControlsButtonRSWP __3hmsj"
            title="Play"
            type="button"
          >
            <svg
              height="1em"
              preserveAspectRatio="xMidYMid"
              viewBox="0 0 64 64"
              width="1em"
            >
              <path
                d="M32 0c17.673 0 32 14.327 32 32 0 17.673-14.327 32-32 32C14.327 64 0 49.673 0 32 0 14.327 14.327 0 32 0Zm-7.61 18.188c-.435.251-.702.715-.701 1.216v25.194a1.402 1.402 0 0 0 2.104 1.214L47.61 33.214a1.402 1.402 0 0 0 0-2.428L25.793 18.188c-.435-.25-.97-.25-1.404 0Z"
                fill="currentColor"
              />
            </svg>
          </button>
        </div>
        <div>
          <button
            aria-label="Next"
            class="ButtonRSWP _ControlsButtonRSWP __3hmsj"
            disabled=""
            title="Next"
            type="button"
          >
            <svg
              height="1em"
              preserveAspectRatio="xMidYMid"
              viewBox="0 0 64 64"
              width="1em"
            >
              <path
                d="M53.486 0a3.2 3.2 0 0 0-3.2 3.2v23.543L4.8.489A3.2 3.2 0 0 0 0 3.255V60.74a3.2 3.2 0 0 0 4.8 2.774l45.486-26.262V60.8a3.2 3.2 0 0 0 3.2 3.2H60.8a3.2 3.2 0 0 0 3.2-3.2V3.2A3.2 3.2 0 0 0 60.8 0h-7.314Z"
                fill="currentColor"
              />
            </svg>
          </button>
        </div>
        <div />
        <div
          class="rswp__volume"
        >
          <div
            class=" _VolumeInlineRSWP __a3qe0k"
            data-component-name="Volume"
            data-value="1"
          >
            <span>
              <svg
                data-component-name="VolumeHigh"
                height="1em"
                preserveAspectRatio="xMidYMid"
                viewBox="0 0 64 64"
                width="1em"
              >
                <path
                  d="M37.963 3.402a2.989 2.989 0 0 1 1.5 2.596v52a3 3 0 0 1-4.5 2.6l-27.7-16C.32 40.572-2.06 31.688 1.943 24.73a14.556 14.556 0 0 1 5.32-5.328l27.7-16a3 3 0 0 1 3 0ZM45 9.542a23.008 23.008 0 0 1 0 44.912V48.25a17.008 17.008 0 0 0 0-32.508Zm-11.532 1.656-23.2 13.4a8.556 8.556 0 0 0 0 14.8l23.2 13.4v-41.6ZM45 22.238a11 11 0 0 1 0 19.52v-19.52Z"
                  fill="currentColor"
                />
              </svg>
            </span>
            <div>
              <div
                class="volume"
                data-component-name="volume-bar"
                style="box-sizing: border-box; display: inline-block; padding: 0px; transition: height 0.4s, width 0.4s; height: 4px; width: 100%;"
              >
                <div
                  class="volume__track"
                  role="presentation"
                  style="background-color: rgb(204, 204, 204); border-radius: 4px; box-sizing: border-box; height: 4px; position: relative; width: 100%;"
                >
                  <div
                    class="volume__range"
                    style="width: 100%; background-color: rgb(102, 102, 102); border-radius: 4px; position: absolute; height: 100%; top: 0px;"
                  />
                  <div
                    role="presentation"
                    style="box-sizing: border-box; height: 4px; position: absolute; transition: height 0.4s, width 0.4s; width: 20px; left: 100%; bottom: 0%;"
                  >
                    <span
                      aria-label="slider handle"
                      aria-orientation="horizontal"
                      aria-valuemax="100"
                      aria-valuemin="0"
                      aria-valuenow="100"
                      class="volume__thumb"
                      role="slider"
                      style="background-color: rgb(0, 0, 0); border: 0px; border-radius: 50%; box-sizing: border-box; display: block; position: absolute; transition: height 0.4s, width 0.4s; height: 10px; left: -5px; top: -3px; width: 10px;"
                      tabindex="0"
                    />
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
      <div
        class=" _SliderRSWP __q14q70"
        data-component-name="Slider"
        data-position="0"
      >
        <div
          class="rswp_progress"
        >
          0:00
        </div>
        <div
          class="slider"
          data-component-name="progress-bar"
          style="box-sizing: border-box; display: inline-block; padding: 0px; transition: height 0.4s, width 0.4s; height: 4px; width: 100%;"
        >
          <div
            class="slider__track"
            role="presentation"
            style="background-color: rgb(204, 204, 204); border-radius: 4px; box-sizing: border-box; height: 4px; position: relative; width: 100%;"
          >
            <div
              class="slider__range"
              style="width: 0%; background-color: rgb(102, 102, 102); border-radius: 4px; position: absolute; height: 100%; top: 0px;"
            />
            <div
              role="presentation"
              style="box-sizing: border-box; height: 4px; position: absolute; transition: height 0.4s, width 0.4s; width: 20px; left: 0%; bottom: 0%;"
            >
              <span
                aria-label="slider handle"
                aria-orientation="horizontal"
                aria-valuemax="100"
                aria-valuemin="0"
                aria-valuenow="0"
                class="slider__thumb"
                role="slider"
                style="background-color: rgb(0, 0, 0); border: 0px; border-radius: 50%; box-sizing: border-box; display: block; position: absolute; transition: height 0.4s, width 0.4s; height: 10px; left: -5px; top: -3px; width: 10px;"
                tabindex="0"
              />
            </div>
          </div>
        </div>
        <div
          class="rswp_duration"
        >
          0:00
        </div>
      </div>
    </div>
  </div>
</div>
`;

exports[`SpotifyWebPlayer > Error listeners > should handle \`account_error\` 1`] = `
<div
  class="PlayerRSWP"
  data-component-name="Player"
  data-ready="false"
  style="background: rgb(255, 255, 255); min-height: 80px;"
>
  <div
    class=" _ErrorRSWP __1rvrb20"
    data-component-name="ErrorMessage"
  >
    Failed to validate Spotify account
  </div>
</div>
`;

exports[`SpotifyWebPlayer > Error listeners > should handle \`authentication_error\` > With Error 1`] = `
<div
  class="PlayerRSWP"
  data-component-name="Player"
  data-ready="false"
  style="background: rgb(255, 255, 255); min-height: 80px;"
>
  <div
    class=" _ErrorRSWP __1rvrb20"
    data-component-name="ErrorMessage"
  >
    Failed to authenticate
  </div>
</div>
`;

exports[`SpotifyWebPlayer > Error listeners > should handle \`initialization_error\` 1`] = `
<div
  class="PlayerRSWP"
  data-component-name="Player"
  data-ready="true"
  style="background: rgb(255, 255, 255); min-height: 80px;"
>
  <div
    class=" _WrapperRSWP __1g3et1y"
    data-component-name="Wrapper"
  >
    <div />
    <div
      class=" _ControlsRSWP __11aa6ep"
      data-component-name="Controls"
      data-playing="false"
    >
      <div
        class=" _ControlsButtonsRSWP __a3qe0k"
      >
        <div
          class="rswp__devices"
        >
          <div>
            <div
              class=" _DevicesRSWP __n5xkwq"
              data-component-name="Devices"
              data-device-id=""
            >
              <div>
                <p>
                  Select other device
                </p>
                <button
                  aria-label="Test Player"
                  class="ButtonRSWP"
                  data-id="df17372ghs982js892js"
                  type="button"
                >
                  <svg
                    height="1em"
                    preserveAspectRatio="xMidYMid"
                    viewBox="0 0 64 64"
                    width="1em"
                  >
                    <path
                      d="M7.226 10.323a7.228 7.228 0 0 1 7.226-7.226h35.096a7.228 7.228 0 0 1 7.226 7.226V37.16a7.226 7.226 0 0 1-7.226 7.226H14.452a7.226 7.226 0 0 1-7.226-7.226V10.323Zm7.226-1.033c-.57 0-1.033.462-1.033 1.033V37.16c0 .57.463 1.033 1.033 1.033h35.096c.57 0 1.033-.463 1.033-1.033V10.323c0-.57-.463-1.033-1.033-1.033H14.452ZM0 57.806a3.097 3.097 0 0 1 3.097-3.096h57.806a3.097 3.097 0 0 1 0 6.193H3.097A3.097 3.097 0 0 1 0 57.806Z"
                      fill="currentColor"
                    />
                  </svg>
                  <span>
                    Test Player
                  </span>
                </button>
                <span />
              </div>
              <button
                aria-label="Devices"
                class="ButtonRSWP"
                title="Devices"
                type="button"
              >
                <svg
                  height="1em"
                  preserveAspectRatio="xMidYMid"
                  viewBox="0 0 64 64"
                  width="1em"
                >
                  <path
                    d="M57 4c3.864 0 7 3.136 7 7v42a7 7 0 0 1-7 7H31a7 7 0 0 1-7-7V11c0-3.864 3.136-7 7-7h26ZM16 54v6H8v-6h8Zm41-44H31a1 1 0 0 0-1 1v42a1 1 0 0 0 1 1h26a1 1 0 0 0 1-1V11a1 1 0 0 0-1-1ZM44 32a8 8 0 1 1 0 16 8 8 0 0 1 0-16ZM16 4v6H7a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h9v6H7a7 7 0 0 1-7-7V11c0-3.864 3.136-7 7-7h9Zm28 12a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z"
                    fill="currentColor"
                  />
                </svg>
              </button>
            </div>
          </div>
        </div>
        <div />
        <div>
          <button
            aria-label="Previous"
            class="ButtonRSWP _ControlsButtonRSWP __3hmsj"
            title="Previous"
            type="button"
          >
            <svg
              height="1em"
              preserveAspectRatio="xMidYMid"
              viewBox="0 0 64 64"
              width="1em"
            >
              <path
                d="M10.514 0a3.2 3.2 0 0 1 3.2 3.2v23.543L59.2.489A3.2 3.2 0 0 1 64 3.255V60.74a3.2 3.2 0 0 1-4.8 2.774L13.714 37.253V60.8a3.2 3.2 0 0 1-3.2 3.2H3.2A3.2 3.2 0 0 1 0 60.8V3.2A3.2 3.2 0 0 1 3.2 0h7.314Z"
                fill="currentColor"
              />
            </svg>
          </button>
        </div>
        <div>
          <button
            aria-label="Play"
            class="ButtonRSWP rswp__toggle _ControlsButtonRSWP __3hmsj"
            title="Play"
            type="button"
          >
            <svg
              height="1em"
              preserveAspectRatio="xMidYMid"
              viewBox="0 0 64 64"
              width="1em"
            >
              <path
                d="M32 0c17.673 0 32 14.327 32 32 0 17.673-14.327 32-32 32C14.327 64 0 49.673 0 32 0 14.327 14.327 0 32 0Zm-7.61 18.188c-.435.251-.702.715-.701 1.216v25.194a1.402 1.402 0 0 0 2.104 1.214L47.61 33.214a1.402 1.402 0 0 0 0-2.428L25.793 18.188c-.435-.25-.97-.25-1.404 0Z"
                fill="currentColor"
              />
            </svg>
          </button>
        </div>
        <div>
          <button
            aria-label="Next"
            class="ButtonRSWP _ControlsButtonRSWP __3hmsj"
            title="Next"
            type="button"
          >
            <svg
              height="1em"
              preserveAspectRatio="xMidYMid"
              viewBox="0 0 64 64"
              width="1em"
            >
              <path
                d="M53.486 0a3.2 3.2 0 0 0-3.2 3.2v23.543L4.8.489A3.2 3.2 0 0 0 0 3.255V60.74a3.2 3.2 0 0 0 4.8 2.774l45.486-26.262V60.8a3.2 3.2 0 0 0 3.2 3.2H60.8a3.2 3.2 0 0 0 3.2-3.2V3.2A3.2 3.2 0 0 0 60.8 0h-7.314Z"
                fill="currentColor"
              />
            </svg>
          </button>
        </div>
        <div />
      </div>
      <div
        class=" _SliderRSWP __q14q70"
        data-component-name="Slider"
        data-position="0"
      >
        <div
          class="rswp_progress"
        >
          0:00
        </div>
        <div
          class="slider"
          data-component-name="progress-bar"
          style="box-sizing: border-box; display: inline-block; padding: 0px; transition: height 0.4s, width 0.4s; height: 4px; width: 100%;"
        >
          <div
            class="slider__track"
            role="presentation"
            style="background-color: rgb(204, 204, 204); border-radius: 4px; box-sizing: border-box; height: 4px; position: relative; width: 100%;"
          >
            <div
              class="slider__range"
              style="width: 0%; background-color: rgb(102, 102, 102); border-radius: 4px; position: absolute; height: 100%; top: 0px;"
            />
            <div
              role="presentation"
              style="box-sizing: border-box; height: 4px; position: absolute; transition: height 0.4s, width 0.4s; width: 20px; left: 0%; bottom: 0%;"
            >
              <span
                aria-label="slider handle"
                aria-orientation="horizontal"
                aria-valuemax="100"
                aria-valuemin="0"
                aria-valuenow="0"
                class="slider__thumb"
                role="slider"
                style="background-color: rgb(0, 0, 0); border: 0px; border-radius: 50%; box-sizing: border-box; display: block; position: absolute; transition: height 0.4s, width 0.4s; height: 10px; left: -5px; top: -3px; width: 10px;"
                tabindex="0"
              />
            </div>
          </div>
        </div>
        <div
          class="rswp_duration"
        >
          0:00
        </div>
      </div>
    </div>
  </div>
</div>
`;

exports[`SpotifyWebPlayer > Error listeners > should handle \`playback_error\` 1`] = `
<div
  class="PlayerRSWP"
  data-component-name="Player"
  data-ready="true"
  style="background: rgb(255, 255, 255); min-height: 80px;"
>
  <div
    class=" _WrapperRSWP __1g3et1y"
    data-component-name="Wrapper"
  >
    <div
      class="rswp__active _InfoRSWP __14asrht"
      data-component-name="Info"
    >
      <a
        aria-label="Main Theme From Trouble Man on SPOTIFY"
        href="https://open.spotify.com/track/6KUjwoHktuX3du8laPVfO8"
        rel="noreferrer"
        target="_blank"
        title="Main Theme From Trouble Man on SPOTIFY"
      >
        <img
          alt="Main Theme From Trouble Man"
          src="https://i.scdn.co/image/10b3bd8afaf3dfa1f302b8f58e059e9802144052"
        />
      </a>
      <div
        class=" _ContentWrapperRSWP __1c53jt9"
      >
        <div
          class=" _ContentRSWP __1of0817"
        >
          <div
            data-type="title-artist-wrapper"
          >
            <div>
              <p>
                <span>
                  <a
                    aria-label="Main Theme From Trouble Man on SPOTIFY"
                    href="https://open.spotify.com/track/6KUjwoHktuX3du8laPVfO8"
                    rel="noreferrer"
                    target="_blank"
                    title="Main Theme From Trouble Man on SPOTIFY"
                  >
                    Main Theme From Trouble Man
                  </a>
                </span>
              </p>
              <p
                title="Marvin Gaye"
              >
                <span>
                  <a
                    aria-label="Marvin Gaye on SPOTIFY"
                    href="https://open.spotify.com/artist/3koiLjNrgRTNbOwViDipeA"
                    rel="noreferrer"
                    target="_blank"
                    title="Marvin Gaye on SPOTIFY"
                  >
                    Marvin Gaye
                  </a>
                </span>
              </p>
            </div>
          </div>
        </div>
        <a
          aria-label="Play on Spotify"
          href="https://open.spotify.com/track/6KUjwoHktuX3du8laPVfO8"
          rel="noreferrer"
          target="_blank"
        >
          <svg
            height="1em"
            preserveAspectRatio="xMidYMid"
            viewBox="0 0 512 160"
            width="3.2em"
          >
            <path
              d="M79.655 0C35.664 0 0 35.663 0 79.654c0 43.993 35.664 79.653 79.655 79.653 43.996 0 79.656-35.66 79.656-79.653 0-43.988-35.66-79.65-79.657-79.65L79.655 0Zm36.53 114.884a4.963 4.963 0 0 1-6.83 1.646c-18.702-11.424-42.246-14.011-69.973-7.676a4.967 4.967 0 0 1-5.944-3.738 4.958 4.958 0 0 1 3.734-5.945c30.343-6.933 56.37-3.948 77.367 8.884a4.965 4.965 0 0 1 1.645 6.83Zm9.75-21.689c-1.799 2.922-5.622 3.845-8.543 2.047-21.41-13.16-54.049-16.972-79.374-9.284a6.219 6.219 0 0 1-7.75-4.138 6.22 6.22 0 0 1 4.141-7.745c28.929-8.778 64.892-4.526 89.48 10.583 2.92 1.798 3.843 5.622 2.045 8.538Zm.836-22.585C101.1 55.362 58.742 53.96 34.231 61.4c-3.936 1.194-8.098-1.028-9.29-4.964a7.453 7.453 0 0 1 4.965-9.294c28.137-8.542 74.912-6.892 104.469 10.655a7.441 7.441 0 0 1 2.606 10.209c-2.092 3.54-6.677 4.707-10.206 2.605h-.004Zm89.944 2.922c-13.754-3.28-16.198-5.581-16.198-10.418 0-4.57 4.299-7.645 10.7-7.645 6.202 0 12.347 2.336 18.796 7.143.19.145.437.203.675.165a.888.888 0 0 0 .6-.367l6.715-9.466a.903.903 0 0 0-.171-1.225c-7.676-6.157-16.313-9.15-26.415-9.15-14.848 0-25.225 8.911-25.225 21.662 0 13.673 8.95 18.515 24.417 22.252 13.155 3.031 15.38 5.57 15.38 10.11 0 5.032-4.49 8.161-11.718 8.161-8.028 0-14.582-2.71-21.906-9.046a.932.932 0 0 0-.656-.218.89.89 0 0 0-.619.313l-7.533 8.96a.906.906 0 0 0 .086 1.256c8.522 7.61 19.004 11.624 30.323 11.624 16 0 26.339-8.742 26.339-22.277.028-11.421-6.81-17.746-23.561-21.821l-.029-.013Zm59.792-13.564c-6.934 0-12.622 2.732-17.321 8.33v-6.3c0-.498-.4-.903-.894-.903h-12.318a.899.899 0 0 0-.894.902v70.009c0 .494.4.903.894.903h12.318a.901.901 0 0 0 .894-.903v-22.097c4.699 5.26 10.387 7.838 17.32 7.838 12.89 0 25.94-9.92 25.94-28.886.019-18.97-13.032-28.894-25.93-28.894l-.01.001Zm11.614 28.893c0 9.653-5.945 16.397-14.468
Download .txt
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
Download .txt
SYMBOL INDEX (142 symbols across 43 files)

FILE: demo/src/App.tsx
  type State (line 57) | interface State {
  function App (line 123) | function App() {

FILE: demo/src/GitHubRepo.tsx
  function GitHubRepo (line 41) | function GitHubRepo() {

FILE: demo/src/components/GlobalStyles.tsx
  function GlobalStyles (line 15) | function GlobalStyles({ hasToken }: any) {

FILE: demo/src/components/RepeatButton.tsx
  function RepeatButton (line 5) | function RepeatButton({

FILE: demo/src/components/ShuffleButton.tsx
  function ShuffleButton (line 5) | function ShuffleButton({

FILE: demo/src/modules/helpers.ts
  constant COOKIE_NAME (line 6) | const COOKIE_NAME = 'RSWP_TOKENS';
  constant SPOTIFY (line 10) | const SPOTIFY = {
  constant API_URL (line 27) | const API_URL = 'https://scripts.gilbarbara.dev/api';
  function getAuthorizeUrl (line 29) | function getAuthorizeUrl() {
  function getCredentials (line 41) | function getCredentials() {
  function setCredentials (line 51) | function setCredentials(credentials: SpotifyCredentials) {
  function login (line 55) | async function login(code: string) {
  function refreshCredentials (line 62) | function refreshCredentials(refreshToken: string) {
  function logout (line 69) | function logout() {
  function parseURIs (line 73) | function parseURIs(input: string): string[] {
  function validateURI (line 79) | function validateURI(input: string): boolean {

FILE: demo/src/types.ts
  type SpotifyCredentials (line 1) | interface SpotifyCredentials {

FILE: src/components/Actions.tsx
  type Props (line 7) | interface Props {
  function Actions (line 45) | function Actions(props: Props) {

FILE: src/components/ClickOutside.tsx
  type Props (line 3) | interface Props {
  function ClickOutside (line 9) | function ClickOutside(props: Props) {

FILE: src/components/Controls.tsx
  type Props (line 20) | interface Props {
  function Controls (line 117) | function Controls(props: Props) {

FILE: src/components/Devices.tsx
  type DeviceList (line 14) | interface DeviceList {
  type Props (line 19) | interface Props {
  function getDeviceIcon (line 163) | function getDeviceIcon(type: string) {
  function Devices (line 175) | function Devices(props: Props) {

FILE: src/components/ErrorMessage.tsx
  function ErrorMessage (line 22) | function ErrorMessage({

FILE: src/components/Info.tsx
  type Props (line 15) | interface Props {
  function Info (line 218) | function Info(props: Props) {

FILE: src/components/Loader.tsx
  function Loader (line 61) | function Loader({ styles: { height, loaderColor, loaderSize } }: Compone...

FILE: src/components/Slider.tsx
  type Props (line 9) | interface Props {
  function Slider (line 47) | function Slider(props: Props) {

FILE: src/components/SpotifyLogo.tsx
  type Props (line 3) | interface Props {
  function SpotifyLogo (line 7) | function SpotifyLogo({ bgColor, ...rest }: Props) {

FILE: src/components/Volume.tsx
  type Props (line 15) | interface Props {
  function Volume (line 123) | function Volume(props: Props) {

FILE: src/components/Wrapper.tsx
  function Wrapper (line 46) | function Wrapper(props: ComponentsProps) {

FILE: src/components/icons/Devices.tsx
  function DevicesIcon (line 1) | function DevicesIcon(props: any) {

FILE: src/components/icons/DevicesComputer.tsx
  function DevicesComputerIcon (line 1) | function DevicesComputerIcon(props: any) {

FILE: src/components/icons/DevicesMobile.tsx
  function DevicesMobileIcon (line 1) | function DevicesMobileIcon(props: any) {

FILE: src/components/icons/DevicesSpeaker.tsx
  function DevicesSpeakerIcon (line 1) | function DevicesSpeakerIcon(props: any) {

FILE: src/components/icons/Favorite.tsx
  function Favorite (line 1) | function Favorite(props: any) {

FILE: src/components/icons/FavoriteOutline.tsx
  function FavoriteOutline (line 1) | function FavoriteOutline(props: any) {

FILE: src/components/icons/Next.tsx
  function Next (line 1) | function Next(props: any) {

FILE: src/components/icons/Pause.tsx
  function Pause (line 1) | function Pause(props: any) {

FILE: src/components/icons/Play.tsx
  function Play (line 1) | function Play(props: any) {

FILE: src/components/icons/Previous.tsx
  function Previous (line 1) | function Previous(props: any) {

FILE: src/components/icons/VolumeHigh.tsx
  function VolumeHigh (line 1) | function VolumeHigh(props: any) {

FILE: src/components/icons/VolumeLow.tsx
  function VolumeLow (line 1) | function VolumeLow(props: any) {

FILE: src/components/icons/VolumeMid.tsx
  function VolumeHigh (line 1) | function VolumeHigh(props: any) {

FILE: src/components/icons/VolumeMute.tsx
  function VolumeMute (line 1) | function VolumeMute(props: any) {

FILE: src/constants.ts
  constant ERROR_TYPE (line 1) | const ERROR_TYPE = {
  constant SPOTIFY_CONTENT_TYPE (line 9) | const SPOTIFY_CONTENT_TYPE = {
  constant STATUS (line 17) | const STATUS = {
  constant TRANSPARENT_COLOR (line 26) | const TRANSPARENT_COLOR = 'rgba(0, 0, 0, 0)';
  constant TYPE (line 28) | const TYPE = {

FILE: src/index.tsx
  type SpotifyPlayer (line 85) | type SpotifyPlayer = Spotify.Player;
  class SpotifyWebPlayer (line 88) | class SpotifyWebPlayer extends PureComponent<Props, State> {
    method constructor (line 142) | constructor(props: Props) {
    method componentDidMount (line 187) | public async componentDidMount() {
    method componentDidUpdate (line 208) | public async componentDidUpdate(previousProps: Props, previousState: S...
    method componentWillUnmount (line 324) | public async componentWillUnmount() {
    method handleCallback (line 338) | private handleCallback(state: CallbackState): void {
    method token (line 612) | private get token(): string {
    method initializeDevices (line 618) | private async initializeDevices(id: string) {
    method isExternalPlayer (line 689) | private get isExternalPlayer(): boolean {
    method toggleSyncInterval (line 793) | private async toggleSyncInterval(shouldSync: boolean) {
    method toggleProgressBar (line 817) | private toggleProgressBar() {
    method render (line 939) | public render() {

FILE: src/modules/getters.ts
  function getBgColor (line 15) | function getBgColor(bgColor: string, fallbackColor?: string): string {
  function getItemImage (line 23) | function getItemImage(item: { images: Spotify.Image[] }): string {
  function getLocale (line 29) | function getLocale(locale?: Partial<Locale>): Locale {
  function getMergedStyles (line 46) | function getMergedStyles(styles?: StylesProps): StylesOptions {
  function getPreloadData (line 71) | async function getPreloadData(
  function getRepeatState (line 151) | function getRepeatState(mode: number): RepeatState {
  function getSpotifyLink (line 163) | function getSpotifyLink(uri: string): string {
  function getSpotifyLinkTitle (line 169) | function getSpotifyLinkTitle(name: string, locale: string): string {
  function getSpotifyURIType (line 173) | function getSpotifyURIType(uri: string): string {
  function getTrackInfo (line 179) | function getTrackInfo(track: Spotify.Track | SpotifyApi.TrackObjectFull)...

FILE: src/modules/helpers.ts
  function isNumber (line 5) | function isNumber(value: unknown): value is number {
  function loadSpotifyPlayer (line 9) | function loadSpotifyPlayer(): Promise<any> {
  function millisecondsToTime (line 31) | function millisecondsToTime(input: number) {
  function parseIds (line 51) | function parseIds(ids: IDs): string[] {
  function parseVolume (line 59) | function parseVolume(value?: unknown): number {
  function round (line 74) | function round(number: number, digits = 2) {
  function validateURI (line 80) | function validateURI(input: string): boolean {

FILE: src/modules/hooks.ts
  function useMediaQuery (line 3) | function useMediaQuery(input: string): boolean {
  function usePrevious (line 43) | function usePrevious<T>(value: T): T {

FILE: src/modules/spotify.ts
  function checkTracksStatus (line 6) | async function checkTracksStatus(token: string, tracks: IDs): Promise<bo...
  function getAlbumTracks (line 16) | async function getAlbumTracks(
  function getArtistTopTracks (line 29) | async function getArtistTopTracks(
  function getDevices (line 42) | async function getDevices(token: string): Promise<SpotifyApi.UserDevices...
  function getPlaybackState (line 52) | async function getPlaybackState(
  function getPlaylistTracks (line 70) | async function getPlaylistTracks(
  function getQueue (line 83) | async function getQueue(token: string): Promise<SpotifyApi.UsersQueueRes...
  function getShow (line 93) | async function getShow(token: string, id: string): Promise<SpotifyApi.Sh...
  function getShowEpisodes (line 103) | async function getShowEpisodes(
  function getTrack (line 117) | async function getTrack(token: string, id: string): Promise<SpotifyApi.T...
  function next (line 127) | async function next(token: string, deviceId?: string): Promise<void> {
  function pause (line 143) | async function pause(token: string, deviceId?: string): Promise<void> {
  function play (line 159) | async function play(
  function previous (line 188) | async function previous(token: string, deviceId?: string): Promise<void> {
  function removeTracks (line 204) | async function removeTracks(token: string, tracks: IDs): Promise<void> {
  function repeat (line 215) | async function repeat(token: string, state: RepeatState, deviceId?: stri...
  function saveTracks (line 231) | async function saveTracks(token: string, tracks: IDs): Promise<void> {
  function seek (line 242) | async function seek(token: string, position: number, deviceId?: string):...
  function setDevice (line 258) | async function setDevice(
  function setVolume (line 273) | async function setVolume(token: string, volume: number, deviceId?: strin...
  function shuffle (line 289) | async function shuffle(token: string, state: boolean, deviceId?: string)...

FILE: src/modules/styled.tsx
  type NanoExtended (line 18) | interface NanoExtended extends NanoRenderer {

FILE: src/types/common.ts
  type ErrorType (line 7) | type ErrorType = (typeof ERROR_TYPE)[keyof typeof ERROR_TYPE];
  type IDs (line 8) | type IDs = string | string[];
  type Layout (line 9) | type Layout = 'responsive' | 'compact';
  type RepeatState (line 10) | type RepeatState = 'off' | 'context' | 'track';
  type Status (line 11) | type Status = (typeof STATUS)[keyof typeof STATUS];
  type StylesProps (line 12) | type StylesProps = Partial<StylesOptions>;
  type Type (line 14) | type Type = (typeof TYPE)[keyof typeof TYPE];
  type CallbackState (line 16) | interface CallbackState extends State {
  type ComponentsProps (line 20) | interface ComponentsProps {
  type CustomComponents (line 26) | interface CustomComponents {
  type Locale (line 37) | interface Locale {
  type PlayOptions (line 51) | interface PlayOptions {
  type Props (line 56) | interface Props {
  type State (line 170) | interface State {
  type StyledProps (line 196) | interface StyledProps {
  type StylesOptions (line 201) | interface StylesOptions {

FILE: src/types/spotify.ts
  type SpotifyAlbum (line 1) | type SpotifyAlbum = Spotify.Album;
  type SpotifyArtist (line 3) | type SpotifyArtist = SpotifyApi.ArtistObjectSimplified;
  type SpotifyDevice (line 5) | type SpotifyDevice = SpotifyApi.UserDevice;
  type SpotifyPlayerCallback (line 7) | type SpotifyPlayerCallback = (token: string) => void;
  type SpotifyPlayOptions (line 9) | interface SpotifyPlayOptions {
  type SpotifyTrack (line 16) | interface SpotifyTrack {
  type WebPlaybackArtist (line 25) | interface WebPlaybackArtist {

FILE: test/fixtures/helpers.ts
  function setBoundingClientRect (line 28) | function setBoundingClientRect(type: 'slider' | 'volume' | 'volumeInline...

FILE: test/index.spec.tsx
  function waitFor (line 25) | async function waitFor(fn: () => void) {
  type SetupProps (line 74) | interface SetupProps extends Partial<Props> {
  class Player (line 80) | class Player {
    method constructor (line 83) | constructor(options: Record<string, any>) {
  function setExternalDevice (line 108) | function setExternalDevice() {
  function setup (line 116) | async function setup(props?: SetupProps) {
Condensed preview — 79 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (312K chars).
[
  {
    "path": ".codesandbox/ci.json",
    "chars": 45,
    "preview": "{\n  \"node\": \"18\",\n  \"sandboxes\": [\"/demo\"]\n}\n"
  },
  {
    "path": ".editorconfig",
    "chars": 289,
    "preview": "# EditorConfig is awesome: http://EditorConfig.org\n\n# top-most EditorConfig file\nroot = true\n\n# Unix-style newlines with"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "chars": 5028,
    "preview": "name: '🐛 Bug report'\ndescription: Report a reproducible bug or regression\nbody:\n  - type: markdown\n    attributes:\n     "
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "chars": 226,
    "preview": "blank_issues_enabled: false\ncontact_links:\n  - name: 🗣 Feature Request / Question / Help\n    url: https://github.com/gil"
  },
  {
    "path": ".github/workflows/main.yml",
    "chars": 1830,
    "preview": "name: CI\n\non:\n  push:\n    branches: ['main']\n    tags: ['v*']\n  pull_request:\n    branches: ['*']\n\n  workflow_dispatch:\n"
  },
  {
    "path": ".gitignore",
    "chars": 63,
    "preview": ".idea/\n.tmp/\ncoverage/\ndemo/pnpm-lock.yaml\ndist/\nnode_modules/\n"
  },
  {
    "path": ".husky/post-merge",
    "chars": 48,
    "preview": "./node_modules/.bin/repo-tools install-packages\n"
  },
  {
    "path": ".husky/pre-commit",
    "chars": 64,
    "preview": "./node_modules/.bin/repo-tools check-remote && npm run validate\n"
  },
  {
    "path": ".prettierignore",
    "chars": 26,
    "preview": "coverage\nlib\nnode_modules\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 848,
    "preview": "# Contributing to react-spotify-web-playback\n\n:+1::tada: First off, thanks for taking the time to contribute! :tada::+1:"
  },
  {
    "path": "LICENSE",
    "chars": 1069,
    "preview": "MIT License\n\nCopyright (c) 2019, Gil Barbara\n\nPermission is hereby granted, free of charge, to any person obtaining\na co"
  },
  {
    "path": "README.md",
    "chars": 10305,
    "preview": "# react-spotify-web-playback\n\n[![npm version](https://badge.fury.io/js/react-spotify-web-playback.svg)](https://www.npmj"
  },
  {
    "path": "demo/package.json",
    "chars": 1152,
    "preview": "{\n  \"name\": \"react-spotify-web-playback-demo\",\n  \"version\": \"0.14.4\",\n  \"description\": \"Demo for react-spotify-web-playb"
  },
  {
    "path": "demo/public/index.html",
    "chars": 1615,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"shortcut icon\" href=\"%PUBLIC_URL%/"
  },
  {
    "path": "demo/public/manifest.json",
    "chars": 306,
    "preview": "{\n  \"short_name\": \"React App\",\n  \"name\": \"Create React App Sample\",\n  \"icons\": [\n    {\n      \"src\": \"favicon.ico\",\n     "
  },
  {
    "path": "demo/src/App.tsx",
    "chars": 17555,
    "preview": "import {\n  DragEventHandler,\n  FormEvent,\n  MouseEvent,\n  ReactNode,\n  useCallback,\n  useEffect,\n  useRef,\n} from 'react"
  },
  {
    "path": "demo/src/GitHubRepo.tsx",
    "chars": 1938,
    "preview": "import styled from '@emotion/styled';\n\nimport { primaryColor } from './modules/theme';\n\nconst Wrapper = styled.a`\n  posi"
  },
  {
    "path": "demo/src/components/GlobalStyles.tsx",
    "chars": 1977,
    "preview": "import { css, Global } from '@emotion/react';\nimport { theme } from '@gilbarbara/components';\n\n// background: linear-gra"
  },
  {
    "path": "demo/src/components/Player.tsx",
    "chars": 641,
    "preview": "import { Layout } from 'react-spotify-web-playback';\nimport { css } from '@emotion/react';\nimport styled from '@emotion/"
  },
  {
    "path": "demo/src/components/RepeatButton.tsx",
    "chars": 1470,
    "preview": "import { ComponentProps, useCallback } from 'react';\nimport { RepeatState, spotifyApi } from 'react-spotify-web-playback"
  },
  {
    "path": "demo/src/components/ShuffleButton.tsx",
    "chars": 1138,
    "preview": "import { ComponentProps, useCallback } from 'react';\nimport { spotifyApi } from 'react-spotify-web-playback';\nimport { B"
  },
  {
    "path": "demo/src/index.tsx",
    "chars": 446,
    "preview": "import { ThemeProvider } from '@emotion/react';\nimport { StrictMode } from 'react';\nimport { createRoot } from 'react-do"
  },
  {
    "path": "demo/src/modules/helpers.ts",
    "chars": 2295,
    "preview": "import { getCookie, removeCookie, setCookie } from '@gilbarbara/cookies';\nimport { MONTH, request } from '@gilbarbara/he"
  },
  {
    "path": "demo/src/modules/theme.ts",
    "chars": 194,
    "preview": "import { mergeTheme } from '@gilbarbara/components';\n\nexport const primaryColor = '#ff6d57';\n\nexport const theme = merge"
  },
  {
    "path": "demo/src/types.ts",
    "chars": 128,
    "preview": "export interface SpotifyCredentials {\n  accessToken: string;\n  expiresAt: number;\n  refreshToken?: string;\n  scope: stri"
  },
  {
    "path": "demo/tsconfig.json",
    "chars": 252,
    "preview": "{\n  \"compilerOptions\": {\n    \"esModuleInterop\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"react-jsx\",\n    \"lib\": [\""
  },
  {
    "path": "package.json",
    "chars": 3897,
    "preview": "{\n  \"name\": \"react-spotify-web-playback\",\n  \"version\": \"0.14.7\",\n  \"description\": \"A React Spotify Web Player\",\n  \"autho"
  },
  {
    "path": "sonar-project.properties",
    "chars": 275,
    "preview": "sonar.projectKey=gilbarbara_react-spotify-web-playback\nsonar.organization=gilbarbara-github\nsonar.source=./src\nsonar.jav"
  },
  {
    "path": "src/components/Actions.tsx",
    "chars": 1087,
    "preview": "import { memo, ReactNode } from 'react';\n\nimport { CssLikeObject, px, styled } from '~/modules/styled';\n\nimport { Layout"
  },
  {
    "path": "src/components/ClickOutside.tsx",
    "chars": 1186,
    "preview": "import { memo, ReactNode, useEffect, useRef } from 'react';\n\ninterface Props {\n  children: ReactNode;\n  isActive: boolea"
  },
  {
    "path": "src/components/Controls.tsx",
    "chars": 4384,
    "preview": "import { memo } from 'react';\n\nimport { CssLikeObject, px, styled } from '~/modules/styled';\n\nimport {\n  CustomComponent"
  },
  {
    "path": "src/components/Devices.tsx",
    "chars": 6540,
    "preview": "import { MouseEvent, useCallback, useState } from 'react';\nimport { CssLikeObject } from 'nano-css';\n\nimport { px, style"
  },
  {
    "path": "src/components/ErrorMessage.tsx",
    "chars": 720,
    "preview": "import { px, styled } from '~/modules/styled';\n\nimport { ComponentsProps, StyledProps } from '~/types';\n\nconst Wrapper ="
  },
  {
    "path": "src/components/Info.tsx",
    "chars": 9703,
    "preview": "import { memo, ReactNode, useEffect, useRef, useState } from 'react';\nimport { opacify } from 'colorizr';\n\nimport { getB"
  },
  {
    "path": "src/components/Loader.tsx",
    "chars": 1487,
    "preview": "import { keyframes, px, styled } from '~/modules/styled';\n\nimport { ComponentsProps, StyledProps } from '~/types';\n\ncons"
  },
  {
    "path": "src/components/Player.tsx",
    "chars": 525,
    "preview": "import { forwardRef } from 'react';\n\nimport { px } from '~/modules/styled';\n\nimport { ComponentsProps } from '~/types';\n"
  },
  {
    "path": "src/components/Slider.tsx",
    "chars": 2520,
    "preview": "import { memo } from 'react';\nimport RangeSlider, { RangeSliderPosition } from '@gilbarbara/react-range-slider';\n\nimport"
  },
  {
    "path": "src/components/SpotifyLogo.tsx",
    "chars": 4003,
    "preview": "import { textColor } from 'colorizr';\n\ninterface Props {\n  bgColor: string;\n}\n\nexport default function SpotifyLogo({ bgC"
  },
  {
    "path": "src/components/Volume.tsx",
    "chars": 6363,
    "preview": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport RangeSlider, { RangeSliderPosition } from '@gil"
  },
  {
    "path": "src/components/Wrapper.tsx",
    "chars": 1122,
    "preview": "import { memo } from 'react';\n\nimport { CssLikeObject, px, styled } from '~/modules/styled';\n\nimport { ComponentsProps, "
  },
  {
    "path": "src/components/icons/Devices.tsx",
    "chars": 573,
    "preview": "export default function DevicesIcon(props: any) {\n  return (\n    <svg height=\"1em\" preserveAspectRatio=\"xMidYMid\" viewBo"
  },
  {
    "path": "src/components/icons/DevicesComputer.tsx",
    "chars": 668,
    "preview": "export default function DevicesComputerIcon(props: any) {\n  return (\n    <svg height=\"1em\" preserveAspectRatio=\"xMidYMid"
  },
  {
    "path": "src/components/icons/DevicesMobile.tsx",
    "chars": 508,
    "preview": "export default function DevicesMobileIcon(props: any) {\n  return (\n    <svg height=\"1em\" preserveAspectRatio=\"xMidYMid\" "
  },
  {
    "path": "src/components/icons/DevicesSpeaker.tsx",
    "chars": 475,
    "preview": "export default function DevicesSpeakerIcon(props: any) {\n  return (\n    <svg height=\"1em\" preserveAspectRatio=\"xMidYMid\""
  },
  {
    "path": "src/components/icons/Favorite.tsx",
    "chars": 499,
    "preview": "export default function Favorite(props: any) {\n  return (\n    <svg height=\"1em\" preserveAspectRatio=\"xMidYMid\" viewBox=\""
  },
  {
    "path": "src/components/icons/FavoriteOutline.tsx",
    "chars": 831,
    "preview": "export default function FavoriteOutline(props: any) {\n  return (\n    <svg height=\"1em\" preserveAspectRatio=\"xMidYMid\" vi"
  },
  {
    "path": "src/components/icons/Next.tsx",
    "chars": 430,
    "preview": "export default function Next(props: any) {\n  return (\n    <svg height=\"1em\" preserveAspectRatio=\"xMidYMid\" viewBox=\"0 0 "
  },
  {
    "path": "src/components/icons/Pause.tsx",
    "chars": 564,
    "preview": "export default function Pause(props: any) {\n  return (\n    <svg height=\"1em\" preserveAspectRatio=\"xMidYMid\" viewBox=\"0 0"
  },
  {
    "path": "src/components/icons/Play.tsx",
    "chars": 494,
    "preview": "export default function Play(props: any) {\n  return (\n    <svg height=\"1em\" preserveAspectRatio=\"xMidYMid\" viewBox=\"0 0 "
  },
  {
    "path": "src/components/icons/Previous.tsx",
    "chars": 432,
    "preview": "export default function Previous(props: any) {\n  return (\n    <svg height=\"1em\" preserveAspectRatio=\"xMidYMid\" viewBox=\""
  },
  {
    "path": "src/components/icons/VolumeHigh.tsx",
    "chars": 649,
    "preview": "export default function VolumeHigh(props: any) {\n  return (\n    <svg\n      data-component-name=\"VolumeHigh\"\n      height"
  },
  {
    "path": "src/components/icons/VolumeLow.tsx",
    "chars": 569,
    "preview": "export default function VolumeLow(props: any) {\n  return (\n    <svg\n      data-component-name=\"VolumeLow\"\n      height=\""
  },
  {
    "path": "src/components/icons/VolumeMid.tsx",
    "chars": 609,
    "preview": "export default function VolumeHigh(props: any) {\n  return (\n    <svg\n      data-component-name=\"VolumeMid\"\n      height="
  },
  {
    "path": "src/components/icons/VolumeMute.tsx",
    "chars": 806,
    "preview": "export default function VolumeMute(props: any) {\n  return (\n    <svg\n      data-component-name=\"VolumeMute\"\n      height"
  },
  {
    "path": "src/constants.ts",
    "chars": 775,
    "preview": "export const ERROR_TYPE = {\n  ACCOUNT: 'account',\n  AUTHENTICATION: 'authentication',\n  INITIALIZATION: 'initialization'"
  },
  {
    "path": "src/index.tsx",
    "chars": 27751,
    "preview": "/* eslint-disable camelcase */\nimport { createRef, PureComponent, ReactNode } from 'react';\nimport isEqual from '@gilbar"
  },
  {
    "path": "src/modules/getters.ts",
    "chars": 4764,
    "preview": "/* eslint-disable camelcase */\nimport { SPOTIFY_CONTENT_TYPE, TRANSPARENT_COLOR } from '~/constants';\nimport { parseIds,"
  },
  {
    "path": "src/modules/helpers.ts",
    "chars": 2126,
    "preview": "import { SPOTIFY_CONTENT_TYPE } from '~/constants';\n\nimport { IDs } from '~/types';\n\nexport function isNumber(value: unk"
  },
  {
    "path": "src/modules/hooks.ts",
    "chars": 1267,
    "preview": "import { useEffect, useRef, useState } from 'react';\n\nexport function useMediaQuery(input: string): boolean {\n  const ge"
  },
  {
    "path": "src/modules/spotify.ts",
    "chars": 7913,
    "preview": "/* eslint-disable camelcase */\nimport { parseIds } from '~/modules/helpers';\n\nimport { IDs, RepeatState, SpotifyPlayOpti"
  },
  {
    "path": "src/modules/styled.tsx",
    "chars": 1338,
    "preview": "/* eslint-disable import/extensions */\n/* tslint:disable:object-literal-sort-keys */\nimport { createElement, FunctionCom"
  },
  {
    "path": "src/types/common.ts",
    "chars": 5176,
    "preview": "import { ReactNode } from 'react';\n\nimport { ERROR_TYPE, STATUS, TYPE } from '~/constants';\n\nimport { SpotifyDevice, Spo"
  },
  {
    "path": "src/types/index.ts",
    "chars": 53,
    "preview": "export * from './common';\nexport * from './spotify';\n"
  },
  {
    "path": "src/types/spotify.ts",
    "chars": 583,
    "preview": "export type SpotifyAlbum = Spotify.Album;\n\nexport type SpotifyArtist = SpotifyApi.ArtistObjectSimplified;\n\nexport type S"
  },
  {
    "path": "test/__setup__/global.d.ts",
    "chars": 49,
    "preview": "import 'jest-extended';\nimport 'vitest/globals';\n"
  },
  {
    "path": "test/__setup__/vitest.setup.ts",
    "chars": 697,
    "preview": "import '@testing-library/jest-dom';\n\nimport { configure } from '@testing-library/react';\nimport * as matchers from 'jest"
  },
  {
    "path": "test/__snapshots__/constants.spec.ts.snap",
    "chars": 923,
    "preview": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`ERROR_TYPE > should have all options 1`] = `\n{\n "
  },
  {
    "path": "test/__snapshots__/index.spec.tsx.snap",
    "chars": 79545,
    "preview": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`SpotifyWebPlayer > Device listeners > should han"
  },
  {
    "path": "test/constants.spec.ts",
    "chars": 738,
    "preview": "import { ERROR_TYPE, SPOTIFY_CONTENT_TYPE, STATUS, TRANSPARENT_COLOR, TYPE } from '~/constants';\n\ndescribe('ERROR_TYPE',"
  },
  {
    "path": "test/fixtures/data.ts",
    "chars": 18309,
    "preview": "export const playerAlbum = {\n  images: [\n    {\n      height: 298,\n      url: 'https://i.scdn.co/image/177f29ea8006359bd7"
  },
  {
    "path": "test/fixtures/helpers.ts",
    "chars": 521,
    "preview": "export const domRect = {\n  slider: {\n    bottom: 50,\n    height: 6,\n    left: 0,\n    right: 0,\n    top: 0,\n    width: 10"
  },
  {
    "path": "test/index.spec.tsx",
    "chars": 23610,
    "preview": "/* eslint-disable testing-library/no-unnecessary-act */\nimport React from 'react';\nimport {\n  act,\n  fireEvent,\n  render"
  },
  {
    "path": "test/modules/__snapshots__/getters.spec.ts.snap",
    "chars": 3013,
    "preview": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`getLocale > should return a merged locale 1`] = "
  },
  {
    "path": "test/modules/getters.spec.ts",
    "chars": 4031,
    "preview": "import { TRANSPARENT_COLOR } from '~/constants';\nimport {\n  getBgColor,\n  getLocale,\n  getMergedStyles,\n  getPreloadData"
  },
  {
    "path": "test/modules/helpers.spec.ts",
    "chars": 2317,
    "preview": "import {\n  isNumber,\n  loadSpotifyPlayer,\n  millisecondsToTime,\n  parseIds,\n  parseVolume,\n  round,\n  validateURI,\n} fro"
  },
  {
    "path": "test/modules/spotify.spec.ts",
    "chars": 4964,
    "preview": "import {\n  checkTracksStatus,\n  getAlbumTracks,\n  getArtistTopTracks,\n  getDevices,\n  getPlaybackState,\n  getPlaylistTra"
  },
  {
    "path": "test/tsconfig.json",
    "chars": 163,
    "preview": "{\n  \"extends\": \"../tsconfig\",\n  \"compilerOptions\": {\n    \"noUnusedLocals\": false,\n    \"esModuleInterop\": true,\n    \"modu"
  },
  {
    "path": "tsconfig.json",
    "chars": 235,
    "preview": "{\n  \"extends\": \"@gilbarbara/tsconfig\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"downlevelIteration\": true,\n    \"n"
  },
  {
    "path": "vitest.config.mts",
    "chars": 723,
    "preview": "import react from '@vitejs/plugin-react-swc';\nimport tsconfigPaths from 'vite-tsconfig-paths';\nimport { defineConfig } f"
  }
]

About this extraction

This page contains the full source code of the gilbarbara/react-spotify-web-playback GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 79 files (287.5 KB), approximately 92.8k tokens, and a symbol index with 142 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!