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
[](https://www.npmjs.com/package/react-spotify-web-playback) [](https://github.com/gilbarbara/react-spotify-web-playback/actions/workflows/main.yml) [](https://sonarcloud.io/summary/new_code?id=gilbarbara_react-spotify-web-playback) [](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',
}}
/>
```

## 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
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
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[](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.