diff --git a/src/Player.js b/src/Player.js index c8242df7..89883ec1 100644 --- a/src/Player.js +++ b/src/Player.js @@ -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) { diff --git a/src/demo/App.js b/src/demo/App.js index de96a7d2..8f6a01f4 100644 --- a/src/demo/App.js +++ b/src/demo/App.js @@ -282,6 +282,13 @@ class App extends Component { {this.renderLoadButton('https://www.youtube.com/playlist?list=PLogRWNZ498ETeQNYrOlqikEML3bKJcdcx', 'Playlist')} + + Spotify + + {this.renderLoadButton('spotify:track:6Uwi2Qk3H7fM4b4W4ExrAp', 'Test A')} + {this.renderLoadButton('spotify:track:0KhB428j00T8lxKCpHweKw', 'Test B')} + + SoundCloud diff --git a/src/patterns.js b/src/patterns.js index 36f6ab7c..cc73bb52 100644 --- a/src/patterns.js +++ b/src/patterns.js @@ -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?|\/).+$/ @@ -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), diff --git a/src/players/Spotify.js b/src/players/Spotify.js new file mode 100644 index 00000000..dbef7571 --- /dev/null +++ b/src/players/Spotify.js @@ -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 ( +
+
+
+ ) + } +} diff --git a/src/players/index.js b/src/players/index.js index 0513b47c..8058f053 100644 --- a/src/players/index.js +++ b/src/players/index.js @@ -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', diff --git a/test/players/Spotify.js b/test/players/Spotify.js new file mode 100644 index 00000000..1113f961 --- /dev/null +++ b/test/players/Spotify.js @@ -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( + + ).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( + + ).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( + + ).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().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().instance() + instance.onStateChange({ data: { isPaused: true } }) +}) + +test('onStateChange() - buffer', async t => { + const onBuffer = () => t.pass() + const instance = shallow().instance() + instance.onStateChange({ data: { isBuffering: true } }) +}) + +test('onStateChange() - ended', async t => { + const onEnded = () => t.pass() + const instance = shallow( {}} onBufferEnd={() => {}} />).instance() + instance.onStateChange({ data: { duration: 100, position: 105, isPaused: false, isBuffering: false } }) +}) + +test('render()', t => { + const wrapper = shallow() + const style = { width: '100%', height: '100%' } + t.true(wrapper.contains( +
+
+
+ )) +}) diff --git a/types/spotify.d.ts b/types/spotify.d.ts new file mode 100644 index 00000000..4e2683cd --- /dev/null +++ b/types/spotify.d.ts @@ -0,0 +1,5 @@ +import BaseReactPlayer, { BaseReactPlayerProps } from './base' + +export interface SpotifyPlayerProps extends BaseReactPlayerProps {} + +export default class SpotifyPlayer extends BaseReactPlayer {}