Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Spotify track/episode support #1603

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Player.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ export default class Player extends Component {

handleEnded = () => {
const { activePlayer, loop, onEnded } = this.props
if (activePlayer.loopOnEnded && loop) {
if ((activePlayer?._result?.loopOnEnded || activePlayer?.loopOnEnded) && loop) {
this.seekTo(0)
}
if (!loop) {
Expand Down
7 changes: 7 additions & 0 deletions src/demo/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,13 @@ class App extends Component {
{this.renderLoadButton('https://www.youtube.com/playlist?list=PLogRWNZ498ETeQNYrOlqikEML3bKJcdcx', 'Playlist')}
</td>
</tr>
<tr>
<th>Spotify</th>
<td>
{this.renderLoadButton('spotify:track:6Uwi2Qk3H7fM4b4W4ExrAp', 'Test A')}
{this.renderLoadButton('spotify:track:0KhB428j00T8lxKCpHweKw', 'Test B')}
</td>
</tr>
<tr>
<th>SoundCloud</th>
<td>
Expand Down
2 changes: 2 additions & 0 deletions src/patterns.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { isMediaStream, isBlobUrl } from './utils'

export const MATCH_URL_YOUTUBE = /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:embed\/|v\/|watch\/|watch\?v=|watch\?.+&v=|shorts\/|live\/))((\w|-){11})|youtube\.com\/playlist\?list=|youtube\.com\/user\//
export const MATCH_URL_SPOTIFY = /spotify.+$/
export const MATCH_URL_SOUNDCLOUD = /(?:soundcloud\.com|snd\.sc)\/[^.]+$/
export const MATCH_URL_VIMEO = /vimeo\.com\/(?!progressive_redirect).+/
export const MATCH_URL_FACEBOOK = /^https?:\/\/(www\.)?facebook\.com.*\/(video(s)?|watch|story)(\.php?|\/).+$/
Expand Down Expand Up @@ -50,6 +51,7 @@ export const canPlay = {
}
return MATCH_URL_YOUTUBE.test(url)
},
spotify: url => MATCH_URL_SPOTIFY.test(url),
soundcloud: url => MATCH_URL_SOUNDCLOUD.test(url) && !AUDIO_EXTENSIONS.test(url),
vimeo: url => MATCH_URL_VIMEO.test(url) && !VIDEO_EXTENSIONS.test(url) && !HLS_EXTENSIONS.test(url),
facebook: url => MATCH_URL_FACEBOOK.test(url) || MATCH_URL_FACEBOOK_WATCH.test(url),
Expand Down
137 changes: 137 additions & 0 deletions src/players/Spotify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import React, { Component } from 'react'
import { getSDK, callPlayer } from '../utils'
import { canPlay } from '../patterns'

const SDK_URL = 'https://open.spotify.com/embed-podcast/iframe-api/v1'
const SDK_GLOBAL = 'SpotifyIframeApi'
const SDK_GLOBAL_READY = 'SpotifyIframeApi'

export default class Spotify extends Component {
static displayName = 'Spotify'
static loopOnEnded = true
static canPlay = canPlay.spotify
callPlayer = callPlayer
duration = null
currentTime = null
totalTime = null
player = null

componentDidMount () {
this.props.onMount && this.props.onMount(this)
}

load (url) {
if (window[SDK_GLOBAL] && !this.player) {
this.initializePlayer(window[SDK_GLOBAL], url)
return
} else if (this.player) {
this.callPlayer('loadUri', this.props.url)
return
}

window.onSpotifyIframeApiReady = (IFrameAPI) => this.initializePlayer(IFrameAPI, url)
getSDK(SDK_URL, SDK_GLOBAL, SDK_GLOBAL_READY)
}

initializePlayer = (IFrameAPI, url) => {
if (!this.container) return

const options = {
width: '100%',
height: '100%',
uri: url
}
const callback = (EmbedController) => {
this.player = EmbedController
this.player.addListener('playback_update', this.onStateChange)
this.player.addListener('ready', this.props.onReady)
}
IFrameAPI.createController(this.container, options, callback)
}

onStateChange = (event) => {
const { data } = event
const { onPlay, onPause, onBuffer, onBufferEnd, onEnded } = this.props

if (data.position >= data.duration && data.position && data.duration) {
onEnded()
}
if (data.isPaused === true) onPause()
if (data.isPaused === false && data.isBuffering === false) {
this.currentTime = data.position
this.totalTime = data.duration
onPlay()
onBufferEnd()
}
if (data.isBuffering === true) onBuffer()
}

play () {
this.callPlayer('resume')
}

pause () {
this.callPlayer('pause')
}

stop () {
this.callPlayer('destroy')
}

seekTo (amount) {
this.callPlayer('seek', amount)
if (!this.props.playing) {
this.pause()
} else {
this.play()
}
}

setVolume (fraction) {
// No volume support
}

mute () {
// No volume support
}

unmute () {
// No volume support
}

setPlaybackRate (rate) {
// No playback rate support
}

setLoop (loop) {
// No loop support
}

getDuration () {
return this.totalTime / 1000
}

getCurrentTime () {
return this.currentTime / 1000
}

getSecondsLoaded () {
// No seconds loaded support
}

ref = container => {
this.container = container
}

render () {
const style = {
width: '100%',
height: '100%'
}
return (
<div style={style}>
<div ref={this.ref} />
</div>
)
}
}
6 changes: 6 additions & 0 deletions src/players/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export default [
canPlay: canPlay.youtube,
lazyPlayer: lazy(() => import(/* webpackChunkName: 'reactPlayerYouTube' */'./YouTube'))
},
{
key: 'spotify',
name: 'Spotify',
canPlay: canPlay.spotify,
lazyPlayer: lazy(() => import(/* webpackChunkName: 'reactPlayerSpotify' */'./Spotify'))
},
{
key: 'soundcloud',
name: 'SoundCloud',
Expand Down
110 changes: 110 additions & 0 deletions test/players/Spotify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React from 'react'
import test from 'ava'
import sinon from 'sinon'
import { shallow } from 'enzyme'
import testPlayerMethods from '../helpers/testPlayerMethods'
import * as utils from '../../src/utils'
import Spotify from '../../src/players/Spotify'

global.window = {}
const TEST_URL = 'spotify:track:0KhB428j00T8lxKCpHweKw'

testPlayerMethods(Spotify, {
play: 'resume',
pause: 'pause',
stop: 'destroy',
seekTo: 'seek'
})

test('load() - Player not initialized and sdk not loaded', t => {
class MockPlayer {
constructor (container, options) {
t.true(container === 'mock-container')
setTimeout(options.events.onReady, 100)
}
}
const getSDK = sinon.stub(utils, 'getSDK').resolves({ MockPlayer })

const instance = shallow(
<Spotify url={TEST_URL} />
).instance()
instance.container = 'mock-container'
instance.load(TEST_URL)
t.truthy(global.window.onSpotifyIframeApiReady)
t.true(getSDK.calledOnce)
getSDK.restore()
})

test('load() - sdk already loaded', t => {
const getSDK = sinon.stub(utils, 'getSDK')
window.SpotifyIframeApi = true

const instance = shallow(
<Spotify url={TEST_URL} />
).instance()
const initializePlayer = sinon.stub(instance, 'initializePlayer')
instance.container = 'mock-container'
instance.load(TEST_URL)
t.false(getSDK.calledOnce)
t.true(initializePlayer.calledOnce)
getSDK.restore()
initializePlayer.restore()
})

test('load() - player already initialized', t => {
const getSDK = sinon.stub(utils, 'getSDK')
window.SpotifyIframeApi = true

const instance = shallow(
<Spotify url={TEST_URL} />
).instance()
instance.player = true
const initializePlayer = sinon.stub(instance, 'initializePlayer')
const callPlayer = sinon.stub(instance, 'callPlayer')
instance.container = 'mock-container'

instance.load(TEST_URL)
t.false(getSDK.calledOnce)
t.false(initializePlayer.calledOnce)
t.true(callPlayer.calledOnce)
getSDK.restore()
initializePlayer.restore()
callPlayer.restore()
})

test('onStateChange() - play', t => {
const called = {}
const onPlay = () => { called.onPlay = true }
const onBufferEnd = () => { called.onBufferEnd = true }
const instance = shallow(<Spotify url={TEST_URL} onPlay={onPlay} onBufferEnd={onBufferEnd} />).instance()
instance.onStateChange({ data: { isPaused: false, isBuffering: false } })
t.true(called.onPlay && called.onBufferEnd)
})

test('onStateChange() - pause', async t => {
const onPause = () => t.pass()
const instance = shallow(<Spotify url={TEST_URL} onPause={onPause} />).instance()
instance.onStateChange({ data: { isPaused: true } })
})

test('onStateChange() - buffer', async t => {
const onBuffer = () => t.pass()
const instance = shallow(<Spotify url={TEST_URL} onBuffer={onBuffer} />).instance()
instance.onStateChange({ data: { isBuffering: true } })
})

test('onStateChange() - ended', async t => {
const onEnded = () => t.pass()
const instance = shallow(<Spotify url={TEST_URL} onEnded={onEnded} onPlay={() => {}} onBufferEnd={() => {}} />).instance()
instance.onStateChange({ data: { duration: 100, position: 105, isPaused: false, isBuffering: false } })
})

test('render()', t => {
const wrapper = shallow(<Spotify url={TEST_URL} />)
const style = { width: '100%', height: '100%' }
t.true(wrapper.contains(
<div style={style}>
<div />
</div>
))
})
5 changes: 5 additions & 0 deletions types/spotify.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import BaseReactPlayer, { BaseReactPlayerProps } from './base'

export interface SpotifyPlayerProps extends BaseReactPlayerProps {}

export default class SpotifyPlayer extends BaseReactPlayer<SpotifyPlayerProps> {}