From 3d1435d6630fe7980c1a3b9a2324b36d65a7e4d2 Mon Sep 17 00:00:00 2001 From: Philippe Desmarais Date: Mon, 27 Mar 2023 23:49:24 -0400 Subject: [PATCH 1/2] feat: Add Spotify track/episode support Using the [Spotify Iframe API](https://developer.spotify.com/documentation/embeds/references/iframe-api), we can embed and control spotify tracks and episodes. To use this feature, pass a link of format "spotify:track:id" or "spotify:episode:id" to the player. I also fixed a small issue related to the loop mechanism being broken when using lazy players. Small note though. To view items, the viewer MUST be logged in with their spotify account (premium not required, unlike the Spotify playback SDK) otherwise they get a 30-sec preview. Functionality is mostly maintained, the clip is simply cut short Should partially address #161 --- src/Player.js | 2 +- src/demo/App.js | 7 ++ src/patterns.js | 2 + src/players/Spotify.js | 141 ++++++++++++++++++++++++++++++++++++++++ src/players/index.js | 6 ++ test/players/Spotify.js | 110 +++++++++++++++++++++++++++++++ types/spotify.d.ts | 5 ++ 7 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 src/players/Spotify.js create mode 100644 test/players/Spotify.js create mode 100644 types/spotify.d.ts 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..42dc7777 --- /dev/null +++ b/src/players/Spotify.js @@ -0,0 +1,141 @@ +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) + } + + onReady () { + this.props.onReady() + } + + 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.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 {} From 49be7584b593474326f653a77bc5cc9d23bee350 Mon Sep 17 00:00:00 2001 From: Philippe Desmarais Date: Tue, 28 Mar 2023 00:10:44 -0400 Subject: [PATCH 2/2] fix: Undefined this.props.onReady onReady wasn't getting triggered by the listener due to scope issues. Moving the onReady call to a different scope should fix the issue --- src/players/Spotify.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/players/Spotify.js b/src/players/Spotify.js index 42dc7777..dbef7571 100644 --- a/src/players/Spotify.js +++ b/src/players/Spotify.js @@ -33,10 +33,6 @@ export default class Spotify extends Component { getSDK(SDK_URL, SDK_GLOBAL, SDK_GLOBAL_READY) } - onReady () { - this.props.onReady() - } - initializePlayer = (IFrameAPI, url) => { if (!this.container) return @@ -48,7 +44,7 @@ export default class Spotify extends Component { const callback = (EmbedController) => { this.player = EmbedController this.player.addListener('playback_update', this.onStateChange) - this.player.addListener('ready', this.onReady) + this.player.addListener('ready', this.props.onReady) } IFrameAPI.createController(this.container, options, callback) }