├── .eslintrc ├── .gitignore ├── .npmignore ├── LICENSE.md ├── Makefile ├── Procfile ├── README.md ├── examples ├── callback.html ├── index.html ├── oembed.html ├── recording.css ├── recording.html ├── recording.js └── streaming.html ├── index.js ├── karma.conf.js ├── package.json ├── serve.js ├── src ├── api.js ├── callback.js ├── config.js ├── connect.js ├── deferred.js ├── dialog │ ├── dialog.js │ ├── popup.js │ └── store.js ├── player-api.js ├── recorder │ ├── audiocontext.js │ ├── getusermedia.js │ └── recorder.js └── stream.js ├── test ├── api-test.js ├── initialize-test.js ├── recorder-test.js └── stream-test.js ├── vendor ├── playback │ ├── index.js │ ├── playback.js │ └── webpack.config.js └── recorderjs │ ├── recorder.js │ └── recorder.worker.js └── webpack.config.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es6": true 6 | }, 7 | "rules": { 8 | "camelcase": 0, 9 | "no-multi-spaces": 0, 10 | "no-new": 2, 11 | "no-underscore-dangle": 0, 12 | "quotes": [2, "single", "avoid-escape"], 13 | "space-after-keywords": [2, "always"], 14 | "strict": [2, "never"], 15 | "no-unused-vars": [2, { "vars": "all", "args": "none" }] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | sdk.js 3 | node_modules 4 | build 5 | .tmp 6 | /vendor/node 7 | /*.worker.js 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | examples 3 | node_modules 4 | src 5 | test 6 | vendor 7 | .tmp 8 | build 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | SoundCloud JavaScript SDK Copyright (c) 2015 SoundCloud, Ltd. (MIT License) 2 | Recorderjs Copyright (c) 2013 Matt Diamond (MIT License) 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining 5 | a copy of this software and associated documentation files (the 6 | "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, 8 | distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to 10 | the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 19 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 20 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 21 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | OS := $(shell uname) 2 | 3 | BUILD_DIR := $(PWD)/build 4 | DESTDIR := $(BUILD_DIR)/system/$(OS) 5 | DESTBIN := $(DESTDIR)/bin 6 | NM_BIN := $(PWD)/node_modules/.bin 7 | 8 | DEP := vendor 9 | 10 | NODE_VERSION := 6.11.1 11 | NODE := nodejs-$(NODE_VERSION) 12 | NPM_BIN := $(DESTBIN)/npm 13 | 14 | export PATH := $(DESTBIN):$(NM_BIN):$(PATH) 15 | 16 | .PHONY: setup build sc-vendor-libs test run publish dirs clean 17 | 18 | node_modules: $(NPM_BIN) package.json 19 | $(NPM_BIN) install 20 | @touch $@ 21 | 22 | setup: $(NPM_BIN) 23 | 24 | build: node_modules 25 | $(NPM_BIN) run build 26 | 27 | test: build 28 | $(NPM_BIN) run test 29 | 30 | run: build 31 | $(NPM_BIN) run serve 32 | 33 | run-with-watcher: build 34 | $(NPM_BIN) run start 35 | 36 | publish: test 37 | IS_NPM=1 $(NPM_BIN) run build 38 | $(NPM_BIN) publish 39 | 40 | dirs: 41 | echo $(DESTDIR) 42 | echo $(DESTBIN) 43 | echo $(NPM_BIN) 44 | 45 | clean: 46 | rm -rf $(NODE_MODULES) $(BUILD_DIR)/* $(TMP) $(DEP)/node sdk.js vendor/playback/playback.js 47 | 48 | sc-vendor-libs: node_modules 49 | $(NPM_BIN) install \ 50 | @sc/scaudio \ 51 | @sc/scaudio-public-api-stream-url-retriever \ 52 | @sc/maestro-core \ 53 | @sc/maestro-loaders \ 54 | @sc/scaudio-controller-html5-player \ 55 | @sc/scaudio-controller-hls-mse-player 56 | $(NPM_BIN) run buildPlayback 57 | 58 | $(NPM_BIN): $(DESTDIR)/usr/lib/$(NODE)/bin/node 59 | @mkdir -p $(@D) 60 | ln -sf $(DESTDIR)/usr/lib/$(NODE)/bin/npm $@ 61 | @touch $@ 62 | 63 | $(DESTDIR)/usr/lib/$(NODE)/bin/node: $(DEP)/node/$(OS)/$(NODE_VERSION).tar.gz 64 | @mkdir -p $(@D) 65 | tar xz -C $(DESTDIR)/usr/lib/$(NODE) --strip-components 1 -f $< 66 | @touch $@ 67 | 68 | $(DEP)/node/$(OS)/$(NODE_VERSION).tar.gz: 69 | http_proxy=$(PROXY) curl -q --create-dirs --fail --location https://nodejs.org/dist/v$(NODE_VERSION)/node-v$(NODE_VERSION)-linux-x64.tar.xz --output $@ 70 | @touch $@ 71 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node serve.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⚠️⚠️DEPRECATED - NO LONGER MAINTAINED⚠️⚠️ 2 | This repository is no longer maintained by the SoundCloud team due to capacity constraints. We're instead focusing our efforts on improving the API & the developer platform. Please note, at the time of updating this, the repo is already not in sync with the latest API changes. 3 | 4 | We recommend the community to fork this repo in order to maintain the SDK. We'd be more than happy to make a reference on our developer that the developers can use different SDKs build by the community. In case you need to reach out to us, please head over to https://github.com/soundcloud/api/issues 5 | 6 | --- 7 | 8 | # SoundCloud JavaScript Next 9 | 10 | ## Setup 11 | 12 | - `make setup` 13 | 14 | This will install the right node version locally. Please be patient. :) 15 | 16 | ## Building source 17 | 18 | - `make build` 19 | 20 | By default, the SDK is built into `build/sdk/sdk-VERSION.js`. Take a look at `webpack.config.js` for details. 21 | 22 | ### Running with the watcher 23 | 24 | This will run webpack with a watcher. The sdk will be rebuilt when you save changes in `src`. 25 | 26 | In addition, webpack will start a development server on `http://localhost:8080/`. This serves the files in the `examples/` folder. 27 | 28 | - `make run-with-watcher` 29 | 30 | ### Running without the watcher (custom server) 31 | 32 | - `make run` 33 | 34 | ## Running tests 35 | 36 | - `make test` 37 | 38 | The test suite uses Karma to execute the tests in Chrome, Firefox, and Safari if available. 39 | 40 | -------------------------------------------------------------------------------- /examples/callback.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Callback 6 | 7 | 8 |

9 | This page should close soon 10 |

11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SDK playground 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /examples/oembed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SDK playground - oEmbed 6 | 7 | 31 | 32 | 33 |

34 | This example demonstrates the usage of the SDK's oEmbed method. 35 |

36 |

37 | It will embed a widget for the given URL and log the HTML code. 38 |

39 |
40 | 41 |
42 |
43 | 44 | 45 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /examples/recording.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: sans-serif; 4 | color: #fff; 5 | } 6 | 7 | video { 8 | position: absolute; 9 | z-index: 0; 10 | background-size: 100% 100%; 11 | top: 0px; 12 | left: 0px; 13 | min-width: 100%; 14 | min-height: 100%; 15 | width: auto; 16 | height: auto; 17 | cursor: pointer; 18 | } 19 | 20 | #recording { 21 | display: inline-block; 22 | z-index: 100; 23 | position: absolute; 24 | color: #f50; 25 | background-color: transparent; 26 | right: 15px; 27 | top: 15px; 28 | opacity: 0; 29 | font-size: 20px; 30 | transition: .2s opacity linear; 31 | text-shadow: 1px 1px rgba(0,0,0,.2); 32 | } 33 | 34 | #recording.visible { 35 | opacity: .9; 36 | } 37 | 38 | canvas { 39 | z-index: -1; 40 | position: absolute; 41 | } 42 | 43 | #overlay { 44 | z-index: 1; 45 | min-width: 100%; 46 | min-height: 100%; 47 | width: auto; 48 | height: auto; 49 | background-color: rgba(0,0,0,.8); 50 | position: absolute; 51 | display: flex; 52 | flex-direction: column; 53 | align-items: center; 54 | justify-content: center; 55 | } 56 | 57 | .button { 58 | background-color: #f50; 59 | color: #fff; 60 | border: none; 61 | border-radius: 5px; 62 | padding: 10px; 63 | font-size: 16px; 64 | display: inline-block; 65 | cursor: pointer; 66 | } 67 | 68 | .button:active { 69 | transform: translateY(1px); 70 | } 71 | 72 | a { 73 | color: #f50; 74 | text-decoration: none; 75 | } 76 | -------------------------------------------------------------------------------- /examples/recording.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SDK playground - Recording 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
recording
15 |
16 |
17 |

18 | This app demonstrates how to record and upload songs with the SoundCloud SDK. 19 |

20 |

21 | After connecting your account, it will record audio as long as you press down on the video. 22 |

23 |

24 | Once you release the video, it will take a snapshot and stop the recording. 25 |

26 |

27 | Then it uploads both to SoundCloud as private track :) 28 |

29 |
Connect
30 |
31 |
32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /examples/recording.js: -------------------------------------------------------------------------------- 1 | SC.initialize({ 2 | client_id: 'YOUR_CLIENT_ID', 3 | redirect_uri: 'http://localhost:8080' 4 | }); 5 | 6 | // background video 7 | var getUserMedia = navigator.getUserMedia || 8 | navigator.webkitGetUserMedia || 9 | navigator.mozGetUserMedia; 10 | var URL = window.URL || window.webkitURL; 11 | var audioContext = new (AudioContext || webkitAudioContext || mozAudioCntext)(); 12 | var video = document.getElementById('video'); 13 | var recording = document.getElementById('recording'); 14 | var height, userMediaStream, width; 15 | 16 | // record flow 17 | var recorder; 18 | var startRecording = function(){ 19 | recording.classList.add('visible'); 20 | if (recorder) { 21 | recorder.stop(); 22 | } 23 | var streamSource = audioContext.createMediaStreamSource(userMediaStream); 24 | recorder = new SC.Recorder({source: streamSource}); 25 | recorder.start(); 26 | }; 27 | 28 | var stopRecording = function(){ 29 | recording.classList.remove('visible'); 30 | if (recorder) { 31 | recorder.stop(); 32 | takePicture(); 33 | } 34 | }; 35 | 36 | video.addEventListener('mousedown', startRecording); 37 | video.addEventListener('touchstart', startRecording); 38 | video.addEventListener('mouseup', stopRecording); 39 | video.addEventListener('touchend', stopRecording); 40 | 41 | // get the user's camera feed 42 | getUserMedia.call(navigator, {video: true, audio: true}, function(stream){ 43 | userMediaStream = stream; 44 | // convert stream to video feed and play it 45 | video.src = URL.createObjectURL(stream); 46 | video.play(); 47 | video.volume = 0; 48 | }, function(error){ 49 | alert('There was a problem in getting the video and audio stream from your device. Did you block the access?'); 50 | }); 51 | 52 | // read the video height when it is playing 53 | video.addEventListener('canplay', function(){ 54 | if (!width || !height) { 55 | width = canvas.width = video.videoWidth; 56 | height = canvas.height = video.videoHeight; 57 | } 58 | }); 59 | 60 | // take picture and upload 61 | var takePicture = function(){ 62 | var context = canvas.getContext('2d'); 63 | context.drawImage(video, 0, 0, width, height); 64 | canvas.toBlob(upload); 65 | }; 66 | 67 | var upload = function(image){ 68 | var title = prompt('Add a title for your recording', 'A very creative title!'); 69 | recorder.getWAV().then(function(wav){ 70 | var upload = SC.upload({ 71 | title: title, 72 | sharing: 'private', 73 | asset_data: wav, 74 | artwork_data: image 75 | }); 76 | upload.then(showTrack); 77 | upload.request.addEventListener('progress', function(event){ 78 | message.innerHTML = 'Uploading (' + (event.loaded / event.total) * 100 + '%)'; 79 | }); 80 | }); 81 | overlay.style.zIndex = 0; 82 | message.innerHTML = 'Uploading...'; 83 | }; 84 | 85 | var showTrack = function(track){ 86 | message.innerHTML = '

Your track has been successfully uploaded!

'; 87 | message.innerHTML += 'Check it out on SoundCloud'; 88 | }; 89 | 90 | // connect flow 91 | var connectButton = document.getElementById('connect'); 92 | connectButton.addEventListener('click', function(){ 93 | // connect to SoundCloud and disable the button 94 | SC.connect().then(function(){ 95 | message.innerHTML = ''; 96 | overlay.style.zIndex = -2; 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /examples/streaming.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SDK playground - Streaming 6 | 7 | 35 | 36 | 37 |
38 | 39 |
40 |
41 | 42 | 43 | 44 |
45 | 46 | 47 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const api = require('./src/api'); 2 | const callback = require('./src/callback'); 3 | const config = require('./src/config'); 4 | const connect = require('./src/connect'); 5 | const Promise = require('es6-promise').Promise; 6 | const Recorder = require('./src/recorder/recorder'); 7 | const stream = require('./src/stream'); 8 | 9 | module.exports = global.SC = { 10 | initialize (options = {}) { 11 | // set tokens 12 | config.set('oauth_token', options.oauth_token); 13 | config.set('client_id', options.client_id); 14 | config.set('redirect_uri', options.redirect_uri); 15 | config.set('baseURL', options.baseURL); 16 | config.set('connectURL', options.connectURL); 17 | }, 18 | 19 | /** API METHODS */ 20 | get (path, params) { 21 | return api.request('GET', path, params); 22 | }, 23 | 24 | post (path, params) { 25 | return api.request('POST', path, params); 26 | }, 27 | 28 | put (path, params) { 29 | return api.request('PUT', path, params); 30 | }, 31 | 32 | delete (path) { 33 | return api.request('DELETE', path); 34 | }, 35 | 36 | upload (options) { 37 | return api.upload(options); 38 | }, 39 | 40 | /** CONNECT METHODS */ 41 | connect (options) { 42 | return connect(options); 43 | }, 44 | 45 | isConnected () { 46 | return config.get('oauth_token') !== undefined; 47 | }, 48 | 49 | /** OEMBED METHODS */ 50 | oEmbed (url, options) { 51 | return api.oEmbed(url, options); 52 | }, 53 | 54 | /** RESOLVE METHODS */ 55 | resolve (url) { 56 | return api.resolve(url); 57 | }, 58 | 59 | /** RECORDER */ 60 | Recorder: Recorder, 61 | 62 | /** PROMISE **/ 63 | Promise: Promise, 64 | 65 | stream (trackPath, secretToken) { 66 | return stream(trackPath, secretToken); 67 | }, 68 | 69 | connectCallback () { 70 | callback.notifyDialog(this.location); 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const VERSION = require('./package.json').version 2 | 3 | module.exports = function(config) { 4 | config.set({ 5 | browsers: ['Chrome', 'Firefox', 'Safari'], 6 | 7 | frameworks: ['chai', 'mocha', 'sinon'], 8 | 9 | files: [ 10 | 'build/sdk/sdk-' + VERSION + '.js', 11 | 'test/*.js' 12 | ] 13 | }); 14 | }; 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "soundcloud", 3 | "version": "3.3.2", 4 | "description": "", 5 | "main": "sdk.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/soundcloud/soundcloud-javascript.git" 9 | }, 10 | "scripts": { 11 | "test": "webpack && karma start --single-run", 12 | "build": "webpack -p", 13 | "buildPlayback": "cd ./vendor/playback && webpack --config webpack.config.js", 14 | "start": "IS_NPM=1 webpack-dev-server", 15 | "serve": "node ./serve.js" 16 | }, 17 | "author": "SoundCloud Ltd.", 18 | "license": "MIT", 19 | "devDependencies": { 20 | "babel-core": "^5.6.15", 21 | "babel-loader": "^5.3.0", 22 | "chai": "^3.2.0", 23 | "eslint": "^1.0.0-rc-1", 24 | "karma": "^0.13.3", 25 | "karma-chai": "^0.1.0", 26 | "karma-chrome-launcher": "^0.2.0", 27 | "karma-firefox-launcher": "^0.1.6", 28 | "karma-mocha": "^0.2.0", 29 | "karma-safari-launcher": "^0.1.1", 30 | "karma-sinon": "^1.0.4", 31 | "mocha": "^2.2.5", 32 | "node-libs-browser": "^0.5.2", 33 | "sinon": "^1.15.4", 34 | "webpack": "^1.10.1", 35 | "webpack-dev-server": "^1.10.1", 36 | "worker-loader": "^0.6.0" 37 | }, 38 | "dependencies": { 39 | "backbone-events-standalone": "^0.2.7", 40 | "es6-promise": "^2.3.0", 41 | "form-urlencoded": "^0.1.4", 42 | "node-static": "^0.7.7", 43 | "query-string": "^2.3.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /serve.js: -------------------------------------------------------------------------------- 1 | var static = require('node-static'); 2 | 3 | var file = new static.Server('./public'); 4 | 5 | require('http').createServer(function (request, response) { 6 | request.addListener('end', function () { 7 | file.serve(request, response); 8 | }).resume(); 9 | }).listen(8080); 10 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const form = require('form-urlencoded'); 3 | const Promise = require('es6-promise').Promise; 4 | 5 | const sendRequest = (method, url, data, progress) => { 6 | let xhr; 7 | const requestPromise = new Promise((resolve) => { 8 | const isFormData = global.FormData && (data instanceof FormData); 9 | xhr = new XMLHttpRequest(); 10 | 11 | if (xhr.upload) { 12 | xhr.upload.addEventListener('progress', progress); 13 | } 14 | xhr.open(method, url, true); 15 | 16 | if (!isFormData) { 17 | xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); 18 | } 19 | 20 | xhr.onreadystatechange = () => { 21 | if (xhr.readyState === 4) { 22 | resolve({responseText: xhr.responseText, request: xhr}); 23 | } 24 | }; 25 | 26 | xhr.send(data); 27 | }); 28 | 29 | requestPromise.request = xhr; 30 | return requestPromise; 31 | }; 32 | 33 | /** 34 | * Parses the public API's response and constructs error messages 35 | * @param {String} responseText The API's raw response 36 | * @param {XMLHttpRequest} xhr The original XMLHttpRequest 37 | * @return {Object({json, error})} An object containing the response and a possible error 38 | */ 39 | const parseResponse = ({responseText, request}) => { 40 | let error, json; 41 | try { 42 | json = JSON.parse(responseText); 43 | } catch (e) { 44 | 45 | } 46 | 47 | if (!json) { 48 | if (request) { 49 | error = { message: `HTTP Error: ${request.status}` }; 50 | } else { 51 | error = { message: 'Unknown error' }; 52 | } 53 | } else if (json.errors) { 54 | error = { message: '' }; 55 | if (json.errors[0] && json.errors[0].error_message) { 56 | error = { message: json.errors[0].error_message }; 57 | } 58 | } 59 | 60 | if (error) { 61 | error.status = request.status; 62 | } 63 | 64 | return { json, error }; 65 | }; 66 | 67 | /** 68 | * Executes the public API request 69 | * @param {String} method The HTTP method (GET, POST, PUT, DELETE) 70 | * @param {String} url The resource's url 71 | * @param {Object} data Data to send along with the request 72 | * @param {Function=} progress upload progress handler 73 | * @return {Promise} 74 | */ 75 | const sendAndFollow = (method, url, data, progress) => { 76 | const requestPromise = sendRequest(method, url, data, progress); 77 | const followPromise = requestPromise.then(({responseText, request}) => { 78 | const response = parseResponse({responseText, request}); 79 | 80 | if (response.json && response.json.status === '302 - Found') { 81 | return sendAndFollow('GET', response.json.location, null); 82 | } else { 83 | if (request.status !== 200 && response.error) { 84 | throw response.error; 85 | } else { 86 | return response.json; 87 | } 88 | } 89 | }); 90 | followPromise.request = requestPromise.request; 91 | return followPromise; 92 | }; 93 | 94 | const addParams = (params, additionalParams, isFormData) => { 95 | Object.keys(additionalParams).forEach((key) => { 96 | if (isFormData) { 97 | params.append(key, additionalParams[key]); 98 | } else { 99 | params[key] = additionalParams[key]; 100 | } 101 | }); 102 | }; 103 | 104 | module.exports = { 105 | /** 106 | * Executes the public API request 107 | * @param {String} method HTTP method 108 | * @param {String} path The resource's path 109 | * @param {(Object|FormData)} params Parameters that will be sent 110 | * @param {Function=} progress optional upload progress handler 111 | * @return {Promise} 112 | */ 113 | request (method, path, params = {}, progress = () => {}) { 114 | const oauthToken = config.get('oauth_token'); 115 | const clientId = config.get('client_id'); 116 | const additionalParams = {}; 117 | const isFormData = global.FormData && (params instanceof FormData); 118 | let data, url; 119 | 120 | additionalParams.format = 'json'; 121 | 122 | // set the oauth_token or, in case none has been issued yet, the client_id 123 | if (oauthToken) { 124 | additionalParams.oauth_token = oauthToken; 125 | } else { 126 | additionalParams.client_id = clientId; 127 | } 128 | 129 | // add the additional params to the received params 130 | addParams(params, additionalParams, isFormData); 131 | 132 | // in case of POST, PUT, DELETE -> prepare data 133 | if (method !== 'GET') { 134 | data = isFormData ? params : form.encode(params); 135 | params = { oauth_token: oauthToken }; 136 | } 137 | 138 | // prepend `/` if not present 139 | path = path[0] !== '/' ? `/${path}` : path; 140 | 141 | // construct request url 142 | url = `${config.get('baseURL')}${path}?${form.encode(params)}`; 143 | 144 | return sendAndFollow(method, url, data, progress); 145 | }, 146 | 147 | /** 148 | * Fetches oEmbed information for the provided URL. 149 | * Also embeds the response into an element if provided in options 150 | * @param {String} trackUrl 151 | * @param {Object} options 152 | * @return {Promise} 153 | */ 154 | oEmbed (trackUrl, options = {}) { 155 | // save element 156 | const element = options.element; 157 | delete options.element; 158 | 159 | options.url = trackUrl; 160 | 161 | // construct URL 162 | const url = `https://soundcloud.com/oembed.json?${form.encode(options)}`; 163 | 164 | // send the request and embed response into element if provided 165 | return sendAndFollow('GET', url, null).then((oEmbed) => { 166 | if (element && oEmbed.html) { 167 | element.innerHTML = oEmbed.html; 168 | } 169 | return oEmbed; 170 | }); 171 | }, 172 | 173 | /** 174 | * Uploads a track to SoundCloud 175 | * @param {Object} options The track's properties 176 | * @param {String} title The track's title 177 | * @param {Blob} file The track's data 178 | * @param {Blob=} artwork_data The track's artwork 179 | * @param {Function=} progress Progress callback 180 | * @return {Promise} 181 | */ 182 | upload (options = {}) { 183 | const file = options.asset_data || options.file; 184 | const canMakeRequest = config.get('oauth_token') && options.title && file; 185 | 186 | if (!canMakeRequest) { 187 | return new Promise((resolve, reject) => { 188 | reject({ 189 | status: 0, 190 | error_message: 'oauth_token needs to be present and title and asset_data / file passed as parameters' 191 | }); 192 | }); 193 | } 194 | 195 | const properties = Object.keys(options); 196 | const formData = new FormData(); 197 | 198 | // add all data to formdata 199 | properties.forEach((property) => { 200 | let value = options[property]; 201 | // `file` is used as short hand for `asset_data` 202 | if (property === 'file') { 203 | property = 'asset_data'; 204 | value = options['file']; 205 | } 206 | 207 | formData.append(`track[${property}]`, value); 208 | }); 209 | 210 | return this.request('POST', '/tracks', formData, options.progress); 211 | }, 212 | 213 | /** 214 | * Resolves a SoundCloud url to a JSON representation of its entity 215 | * @param {String} url The URL that should get resolved 216 | * @return {Promise} 217 | */ 218 | resolve (url) { 219 | return this.request('GET', '/resolve', { 220 | url: url, 221 | /* 222 | * Tell the API not to serve a redirect. This is to get around 223 | * CORS issues on Safari 7+, which likes to send pre-flight requests 224 | * before following redirects, which has problems. 225 | */ 226 | _status_code_map: { 302: 200 } 227 | }); 228 | } 229 | }; 230 | -------------------------------------------------------------------------------- /src/callback.js: -------------------------------------------------------------------------------- 1 | const qs = require('query-string'); 2 | const dialogStore = require('./dialog/store'); 3 | 4 | module.exports = { 5 | /** 6 | * Finds a dialog and passes it the callback's options 7 | * @param {Object} options The callback's options 8 | */ 9 | notifyDialog (location) { 10 | // in the original implementation, values are read from search and hash 11 | // maybe this is due to the fact, that it might change in the future 12 | // using both values here then as well 13 | const searchParams = qs.parse(location.search); 14 | const hashParams = qs.parse(location.hash); 15 | const options = { 16 | oauth_token: searchParams.access_token || hashParams.access_token, 17 | dialog_id: searchParams.state || hashParams.state, 18 | error: searchParams.error || hashParams.error, 19 | error_description: searchParams.error_description || hashParams.error_description 20 | }; 21 | 22 | const dialog = dialogStore.get(options.dialog_id); 23 | if (dialog) { 24 | dialog.handleConnectResponse(options); 25 | } 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | oauth_token: undefined, 3 | baseURL: 'https://api.soundcloud.com', 4 | connectURL: '//connect.soundcloud.com', 5 | client_id: undefined, 6 | redirect_uri: undefined 7 | }; 8 | 9 | module.exports = { 10 | get(key) { 11 | return config[key]; 12 | }, 13 | 14 | set(key, value) { 15 | if (value) { 16 | config[key] = value; 17 | } 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/connect.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const Dialog = require('./dialog/dialog'); 3 | const Promise = require('es6-promise').Promise; 4 | 5 | /** 6 | * Sets the oauth_token to the value that was provided by the callback 7 | * @param {Object} options The callback's parameters 8 | * @return {Object} The callback's parameters 9 | */ 10 | const setOauthToken = (options) => { 11 | config.set('oauth_token', options.oauth_token); 12 | return options; 13 | }; 14 | 15 | module.exports = function (options = {}) { 16 | // resolve immediately when oauth_token is set 17 | const oauth_token = config.get('oauth_token'); 18 | if (oauth_token) { 19 | return new Promise((resolve) => { resolve({oauth_token}); }); 20 | } 21 | // set up the options for the dialog 22 | // make `client_id`, `redirect_uri` and `scope` overridable 23 | const dialogOptions = { 24 | client_id: options.client_id || config.get('client_id'), 25 | redirect_uri: options.redirect_uri || config.get('redirect_uri'), 26 | response_type: 'code_and_token', 27 | scope: options.scope || 'non-expiring', 28 | display: 'popup' 29 | }; 30 | 31 | // `client_id` and `redirect_uri` have to be passed 32 | if (!dialogOptions.client_id || !dialogOptions.redirect_uri) { 33 | throw new Error('Options client_id and redirect_uri must be passed'); 34 | } 35 | 36 | // set up and open the dialog 37 | // set access token when user is done 38 | let dialog = new Dialog(dialogOptions); 39 | return dialog.open().then(setOauthToken); 40 | }; 41 | -------------------------------------------------------------------------------- /src/deferred.js: -------------------------------------------------------------------------------- 1 | const Promise = require('es6-promise').Promise; 2 | 3 | module.exports = function(){ 4 | let deferred = {}; 5 | 6 | deferred.promise = new Promise(function(resolve, reject) { 7 | deferred.resolve = resolve; 8 | deferred.reject = reject; 9 | }); 10 | 11 | return deferred; 12 | }; 13 | -------------------------------------------------------------------------------- /src/dialog/dialog.js: -------------------------------------------------------------------------------- 1 | const deferred = require('../deferred'); 2 | const dialogStore = require('./store'); 3 | const popup = require('./popup'); 4 | const qs = require('query-string'); 5 | 6 | const ID_PREFIX = 'SoundCloud_Dialog'; 7 | 8 | /** 9 | * Generates an id for the connect dialog 10 | * @return {String} id 11 | */ 12 | const generateId = () => { 13 | return [ID_PREFIX, Math.ceil(Math.random() * 1000000).toString(16)].join('_'); 14 | }; 15 | 16 | /** 17 | * Build the SoundCloud connect url 18 | * @param {Object} options The options that will be passed on to the connect screen 19 | * @return {String} The constructed URL 20 | */ 21 | const createURL = (options) => { 22 | return `https://soundcloud.com/connect?${qs.stringify(options)}`; 23 | } 24 | 25 | class Dialog { 26 | constructor (options = {}) { 27 | this.id = generateId(); 28 | this.options = options; 29 | // will be used to identify the correct popup window 30 | this.options.state = this.id; 31 | this.width = 420; 32 | this.height = 670; 33 | 34 | this.deferred = deferred(); 35 | } 36 | 37 | /** 38 | * Opens the dialog and returns a promise that fulfills when the 39 | * user has successfully connected 40 | * @return {Promise} 41 | */ 42 | open () { 43 | const url = createURL(this.options); 44 | this.popup = popup.open(url, this.width, this.height); 45 | dialogStore.set(this.id, this); 46 | return this.deferred.promise; 47 | } 48 | 49 | /** 50 | * Resolves or rejects the dialog's promise based on the provided response. 51 | * (Is initiated from the callback module) 52 | * @param {Object} options The callback's response 53 | */ 54 | handleConnectResponse (options) { 55 | const hasError = options.error; 56 | // resolve or reject the dialog's promise, based on the callback's response 57 | if (hasError) { 58 | this.deferred.reject(options); 59 | } else { 60 | this.deferred.resolve(options); 61 | } 62 | // close the popup 63 | this.popup.close(); 64 | } 65 | } 66 | 67 | module.exports = Dialog; 68 | -------------------------------------------------------------------------------- /src/dialog/popup.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | /** 3 | * Opens a centered popup with the specified URL 4 | * @param {String} url 5 | * @param {Number} width 6 | * @param {Number} height 7 | * @return {Window} A reference to the popup 8 | */ 9 | open (url, width, height) { 10 | const options = {}; 11 | let stringOptions; 12 | 13 | options.location = 1; 14 | options.width = width; 15 | options.height = height; 16 | options.left = window.screenX + (window.outerWidth - width) / 2; 17 | options.top = window.screenY + (window.outerHeight - height) / 2; 18 | options.toolbar = 'no'; 19 | options.scrollbars = 'yes'; 20 | 21 | stringOptions = Object.keys(options).map((key) => { 22 | return `${key}=${options[key]}`; 23 | }).join(', '); 24 | 25 | return window.open(url, options.name, stringOptions); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/dialog/store.js: -------------------------------------------------------------------------------- 1 | const dialogStore = {}; 2 | 3 | module.exports = { 4 | get (dialogId) { 5 | return dialogStore[dialogId]; 6 | }, 7 | 8 | set (dialogId, dialog) { 9 | dialogStore[dialogId] = dialog; 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /src/player-api.js: -------------------------------------------------------------------------------- 1 | const BackboneEvents = require('backbone-events-standalone'); 2 | const { errors: { PlayerFatalError }, State } = require('../vendor/playback/playback').MaestroCore; 3 | const { errors: { NoStreamsError, NotSupportedError } } = require('../vendor/playback/playback').SCAudio; 4 | 5 | const TIMEUPDATE_INTERVAL = 1000 / 60; 6 | 7 | module.exports = function(scaudioPlayer) { 8 | function getState() { 9 | switch (scaudioPlayer.getState()) { 10 | case State.PLAYING: 11 | return 'playing'; 12 | case State.PAUSED: 13 | return scaudioPlayer.isEnded() ? 'ended' : 'paused'; 14 | case State.DEAD: 15 | return scaudioPlayer.getFatalError() ? 'error' : 'dead'; 16 | case State.LOADING: 17 | default: 18 | return 'loading'; 19 | } 20 | } 21 | 22 | function handleEmittingTimeEvents() { 23 | let timerId = 0; 24 | let previousPosition = null; 25 | scaudioPlayer.onChange.subscribe(({ playing, seeking, dead }) => { 26 | if (dead) { 27 | window.clearTimeout(timerId); 28 | } else if (playing !== undefined || seeking !== undefined) { 29 | doEmit(); 30 | } 31 | }); 32 | function doEmit() { 33 | window.clearTimeout(timerId); 34 | if (scaudioPlayer.isPlaying() && !scaudioPlayer.isEnded()) { 35 | timerId = window.setTimeout(doEmit, TIMEUPDATE_INTERVAL); 36 | } 37 | const newPosition = scaudioPlayer.getPosition(); 38 | if (newPosition !== previousPosition) { 39 | previousPosition = newPosition; 40 | playerApi.trigger('time', newPosition); 41 | } 42 | } 43 | } 44 | let hadFirstPlay = false; 45 | scaudioPlayer.onStateChange.subscribe(() => playerApi.trigger('state-change', getState())); 46 | scaudioPlayer.onPlay.subscribe(() => { 47 | playerApi.trigger(hadFirstPlay ? 'play-resume' : 'play-start'); 48 | hadFirstPlay = true; 49 | }); 50 | 51 | scaudioPlayer.onPlayIntent.subscribe(() => playerApi.trigger('play')); 52 | scaudioPlayer.onPlayRejection.subscribe((playRejection) => playerApi.trigger('play-rejection', playRejection)); 53 | scaudioPlayer.onPauseIntent.subscribe(() => playerApi.trigger('pause')); 54 | scaudioPlayer.onSeek.subscribe(() => playerApi.trigger('seeked')); 55 | scaudioPlayer.onSeekRejection.subscribe((seekRejection) => playerApi.trigger('seek-rejection', seekRejection)); 56 | scaudioPlayer.onLoadStart.subscribe(() => playerApi.trigger('buffering_start')); 57 | scaudioPlayer.onLoadEnd.subscribe(() => playerApi.trigger('buffering_end')); 58 | scaudioPlayer.onEnded.subscribe(() => playerApi.trigger('finish')); 59 | scaudioPlayer.onError.subscribe((error) => { 60 | if (error instanceof NoStreamsError) { 61 | playerApi.trigger('no_streams'); 62 | } else if (error instanceof NotSupportedError) { 63 | playerApi.trigger('no_protocol'); 64 | } else if (error instanceof PlayerFatalError) { 65 | playerApi.trigger('audio_error'); 66 | } 67 | }); 68 | 69 | const playerApi = { 70 | play: scaudioPlayer.play.bind(scaudioPlayer), 71 | pause: scaudioPlayer.pause.bind(scaudioPlayer), 72 | seek: scaudioPlayer.seek.bind(scaudioPlayer), 73 | getVolume: scaudioPlayer.getVolume.bind(scaudioPlayer), 74 | setVolume: scaudioPlayer.setVolume.bind(scaudioPlayer), 75 | currentTime: scaudioPlayer.getPosition.bind(scaudioPlayer), 76 | getDuration: scaudioPlayer.getDuration.bind(scaudioPlayer), 77 | isBuffering: scaudioPlayer.isLoading.bind(scaudioPlayer), 78 | isPlaying: scaudioPlayer.isPlaying.bind(scaudioPlayer), 79 | isActuallyPlaying: scaudioPlayer.isActuallyPlaying.bind(scaudioPlayer), 80 | isEnded: scaudioPlayer.isEnded.bind(scaudioPlayer), 81 | isDead: scaudioPlayer.isDead.bind(scaudioPlayer), 82 | kill: scaudioPlayer.kill.bind(scaudioPlayer), 83 | hasErrored: () => !!scaudioPlayer.getFatalError(), 84 | getState 85 | }; 86 | BackboneEvents.mixin(playerApi); 87 | handleEmittingTimeEvents(); 88 | return playerApi; 89 | } -------------------------------------------------------------------------------- /src/recorder/audiocontext.js: -------------------------------------------------------------------------------- 1 | const AudioContext = global.AudioContext || global.webkitAudioContext; 2 | let context = null; 3 | 4 | module.exports = () => { 5 | return context ? context : (context = new AudioContext()); 6 | }; 7 | -------------------------------------------------------------------------------- /src/recorder/getusermedia.js: -------------------------------------------------------------------------------- 1 | const getUserMedia = global.navigator.getUserMedia || 2 | global.navigator.webkitGetUserMedia || 3 | global.navigator.mozGetUserMedia; 4 | 5 | module.exports = (options, success, error) => { 6 | getUserMedia.call(global.navigator, options, success, error); 7 | }; 8 | -------------------------------------------------------------------------------- /src/recorder/recorder.js: -------------------------------------------------------------------------------- 1 | const audioContext = require('./audiocontext'); 2 | const getUserMedia = require('./getusermedia'); 3 | const Promise = require('es6-promise').Promise; 4 | const RecorderJS = require('../../vendor/recorderjs/recorder'); 5 | 6 | 7 | /** 8 | * Sets up the source node by either returning the provided source 9 | * or by requesting access to the browser's microphone 10 | * @return {Promise.} The AudioNode that has been set up 11 | */ 12 | const initSource = function() { 13 | const context = this.context; 14 | 15 | // if a source was passed, use it, otherwise, request it 16 | return new Promise((resolve, reject) => { 17 | if (this.source) { 18 | if (!(this.source instanceof AudioNode)) { 19 | reject(new Error('source needs to be an instance of AudioNode')); 20 | } else { 21 | resolve(this.source); 22 | } 23 | } else { 24 | getUserMedia({audio: true}, ((stream) => { 25 | this.stream = stream; 26 | this.source = context.createMediaStreamSource(stream); 27 | resolve(this.source); 28 | }).bind(this), reject); 29 | } 30 | }); 31 | } 32 | 33 | /** 34 | * Uses the Web Audio API to record audio and to play it. 35 | * Also leverages the internal api module to upload recordings 36 | */ 37 | class Recorder { 38 | 39 | /** 40 | * Initializes the Recorder 41 | * @param {Object=} options 42 | * @param {AudioContext} options.context The AudioContext to use for recording 43 | * @param {AudioNode} options.source An AudioNode that should be used for recording 44 | */ 45 | constructor (options = {}) { 46 | this.context = options.context || audioContext(); 47 | this._recorder = null; 48 | this.source = options.source; 49 | this.stream = null; 50 | } 51 | 52 | /** 53 | * Starts the recording from the browser's microphone or 54 | * form the `source` that was provided in the constructor. 55 | * @return {Promise.} The AudioNode that is used for recording 56 | */ 57 | start () { 58 | return initSource.call(this).then((source) => { 59 | this._recorder = new RecorderJS(source); 60 | this._recorder.record(); 61 | return source; 62 | }); 63 | } 64 | 65 | /** 66 | * Stops the recording 67 | */ 68 | stop () { 69 | // stop the recording 70 | if (this._recorder) { 71 | this._recorder.stop(); 72 | } 73 | 74 | // stop the input media stream 75 | if (this.stream) { 76 | // stream.stop() has been deprecated 77 | // https://developers.google.com/web/updates/2015/07/mediastream-deprecations?hl=en 78 | if (this.stream.stop) { 79 | this.stream.stop(); 80 | } else if (this.stream.getTracks) { 81 | const stream = this.stream.getTracks()[0]; 82 | if (stream) stream.stop(); 83 | } 84 | } 85 | } 86 | 87 | /** 88 | * Creates a buffer from the recording 89 | * @return {Promise.} The AudioBuffer 90 | */ 91 | getBuffer () { 92 | return new Promise((resolve, reject) => { 93 | if (this._recorder) { 94 | this._recorder.getBuffer(((buffer) => { 95 | const sampleRate = this.context.sampleRate; 96 | const theBuffer = this.context.createBuffer(2, buffer[0].length, sampleRate); 97 | theBuffer.getChannelData(0).set(buffer[0]); 98 | theBuffer.getChannelData(1).set(buffer[1]); 99 | resolve(theBuffer); 100 | }).bind(this)); 101 | } else { 102 | reject(new Error('Nothing has been recorded yet.')); 103 | } 104 | }); 105 | } 106 | 107 | /** 108 | * Creates a WAV blob from the recording 109 | * @return {Promise.} The recording as a WAV Blob 110 | */ 111 | getWAV () { 112 | return new Promise((resolve, reject) => { 113 | if (this._recorder) { 114 | this._recorder.exportWAV((blob) => { 115 | resolve(blob); 116 | }); 117 | } else { 118 | reject(new Error('Nothing has been recorded yet.')); 119 | } 120 | }); 121 | } 122 | 123 | /** 124 | * Plays the recording 125 | * @return {Promise.} The AudioNode that is used to play the recording 126 | */ 127 | play () { 128 | return this.getBuffer().then((buffer) => { 129 | const bufferSource = this.context.createBufferSource(); 130 | bufferSource.buffer = buffer; 131 | bufferSource.connect(this.context.destination); 132 | bufferSource.start(0); 133 | return bufferSource; 134 | }); 135 | } 136 | 137 | /** 138 | * Initiates the download of the wav file 139 | * @param {[type]} filename [description] 140 | * @return {[type]} [description] 141 | */ 142 | saveAs (filename) { 143 | return this.getWAV().then((blob) => { 144 | RecorderJS.forceDownload(blob, filename); 145 | }); 146 | } 147 | 148 | /** 149 | * Deletes and stops the recording 150 | */ 151 | delete () { 152 | if (this._recorder) { 153 | this._recorder.stop(); 154 | this._recorder.clear(); 155 | this._recorder = null; 156 | } 157 | 158 | if (this.stream) { 159 | this.stream.stop(); 160 | } 161 | } 162 | } 163 | 164 | module.exports = Recorder; 165 | -------------------------------------------------------------------------------- /src/stream.js: -------------------------------------------------------------------------------- 1 | const api = require('./api'); 2 | const config = require('./config'); 3 | const playerApi = require('./player-api'); 4 | const SCAudio = require('../vendor/playback/playback').SCAudio; 5 | const maestroLogger = require('../vendor/playback/playback').MaestroCore.logger; 6 | const StreamUrlRetriever = require('../vendor/playback/playback').SCAudioPublicApiStreamURLRetriever.StreamUrlRetriever; 7 | const MediaElementManager = require('../vendor/playback/playback').SCAudioControllerHTML5Player.MediaElementManager; 8 | const HTML5PlayerController = require('../vendor/playback/playback').SCAudioControllerHTML5Player.HTML5PlayerController; 9 | const HLSMSEPlayerController = require('../vendor/playback/playback').SCAudioControllerHLSMSEPlayer.HLSMSEPlayerController; 10 | const stringLoader = require('../vendor/playback/playback').MaestroLoaders.stringLoader; 11 | 12 | const mediaElementManager = new MediaElementManager('audio', maestroLogger.noOpLogger); 13 | 14 | /** 15 | * Fetches track info and instantiates a player for the track 16 | * @param {String} trackPath The track's path (/tracks/:track_id) 17 | * @param {String=} secretToken If the track is secret, provide the secret token here 18 | * @return {Promise} 19 | */ 20 | module.exports = (trackPath, secretToken) => { 21 | const options = secretToken ? {secret_token: secretToken} : {}; 22 | 23 | return api.request('GET', trackPath, options).then((track) => { 24 | function registerPlay() { 25 | let registerEndpoint = `${baseURL}/tracks/${encodeURIComponent(track.id)}/plays?client_id=${encodeURIComponent(clientId)}`; 26 | if (secretToken) { 27 | registerEndpoint += `&secret_token=${encodeURIComponent(secretToken)}`; 28 | } 29 | const xhr = new XMLHttpRequest(); 30 | xhr.open('POST', registerEndpoint, true); 31 | xhr.send(); 32 | } 33 | 34 | const baseURL = config.get('baseURL') 35 | const clientId = config.get('client_id'); 36 | const oauthToken = config.get('oauth_token'); 37 | 38 | let playRegistered = false; 39 | const streamUrlRetriever = new StreamUrlRetriever({ 40 | clientId, 41 | secretToken, 42 | trackId: track.id, 43 | requestAuthorization: oauthToken ? 'OAuth ' + oauthToken : null, 44 | loader: stringLoader 45 | }); 46 | 47 | const player = new SCAudio.Player({ 48 | controllers: [ 49 | new HLSMSEPlayerController(mediaElementManager), 50 | new HTML5PlayerController(mediaElementManager) 51 | ], 52 | streamUrlRetriever, 53 | getURLOpts: { preview: track.policy === 'SNIP' }, 54 | streamUrlsExpire: true, 55 | mediaSessionEnabled: true, 56 | logger: maestroLogger.noOpLogger 57 | }); 58 | 59 | player.onPlay.subscribe(() => { 60 | if (!playRegistered) { 61 | playRegistered = true; 62 | registerPlay(); 63 | } 64 | }); 65 | player.onEnded.subscribe(() => { 66 | // maestro keeps the old playing state when at the end. Call pause() to maintain backwards compatibility 67 | player.pause(); 68 | }); 69 | player.onPlayIntent.subscribe(() => { 70 | if (player.isEnded()) { 71 | // seek back to 0 if the user calls play() and we're at the end. 72 | player.seek(0); 73 | } 74 | }); 75 | return playerApi(player); 76 | }); 77 | }; 78 | 79 | /** 80 | * Call this from a user interaction, before creating a player, to ensure that playback 81 | * can start even if `play()` is not from a user interaction. 82 | */ 83 | module.exports.activateAudioElement = () => { 84 | mediaElementManager.activate(); 85 | }; 86 | -------------------------------------------------------------------------------- /test/api-test.js: -------------------------------------------------------------------------------- 1 | describe('API methods', function () { 2 | beforeEach(function () { 3 | this.xhr = sinon.useFakeXMLHttpRequest(); 4 | 5 | this.requests = []; 6 | this.xhr.onCreate = function(xhr) { 7 | this.requests.push(xhr); 8 | }.bind(this); 9 | 10 | SC.initialize({ 11 | client_id: 'YOUR_CLIENT_ID', 12 | redirect_uri: 'http://localhost:8080' 13 | }); 14 | }); 15 | 16 | afterEach(function() { 17 | this.xhr.restore(); 18 | }); 19 | 20 | it('should return the request with the promise', function(){ 21 | var promise = SC.get('tracks/its-a-deep-bark'); 22 | assert.isNotNull(promise.request); 23 | assert.instanceOf(promise.request, XMLHttpRequest); 24 | }); 25 | 26 | it('should return the correct status if a request has an empty response', function(done){ 27 | SC.get('tracks/its-a-deep-bark342').then(function(){ 28 | assert.fail(); 29 | done() 30 | }).catch(function(error){ 31 | assert.equal(error.status, 404); 32 | assert.equal(error.message, 'HTTP Error: 404'); 33 | done(); 34 | }); 35 | this.requests[0].respond(404, 36 | { 'Content-Type': 'text/json' }, 37 | ''); 38 | }); 39 | 40 | describe('GET calls', function () { 41 | it('should make a GET against tracks', function (done) { 42 | SC.get('tracks/its-a-deep-bark').then(function (track) { 43 | assert.equal(track.kind, 'track', 'track GET returns a track'); 44 | assert.equal(this.requests[0].method, 'GET'); 45 | done(); 46 | }.bind(this)).catch(function (err) { 47 | done(err); 48 | }); 49 | this.requests[0].respond(200, 50 | { 'Content-Type': 'text/json' }, 51 | '{"kind": "track"}'); 52 | }); 53 | 54 | it('should trigger a promise catch on a 500 with bad JSON', function (done) { 55 | SC.get('tracks/its-a-deep-bark').then(function (track) { 56 | assert.fail('Promise should not resolve'); 57 | done(); 58 | }.bind(this)).catch(function (err) { 59 | assert.ok(err, 'Promise.catch is sent an error object'); 60 | assert.equal(err.status, 500, 'error.status is a 500'); 61 | done(); 62 | }); 63 | this.requests[0].respond(500, 64 | { 'Content-Type': 'text/json' }, 65 | '{"errors": [{"error_message": "server explosion"}]'); 66 | }); 67 | 68 | it('should trigger a promise catch on a 500 with error JSON', function (done) { 69 | SC.get('tracks/its-a-deep-bark').then(function (track) { 70 | assert.fail('Promise should not resolve'); 71 | done(); 72 | }.bind(this)).catch(function (err) { 73 | assert.ok(err, 'Promise.catch is sent and error object'); 74 | assert.equal(err.status, 500, 'error.status is a 500'); 75 | assert.equal(err.message, 'server explosion'); 76 | done(); 77 | }); 78 | this.requests[0].respond(500, 79 | { 'Content-Type': 'text/json' }, 80 | '{"errors": [{"error_message": "server explosion"}]}'); 81 | }); 82 | 83 | it('should trigger a promise catch on a 404', function (done) { 84 | SC.get('tracks/its-a-deep-bark').then(function (track) { 85 | assert.fail('Promise should not resolve'); 86 | done(); 87 | }.bind(this)).catch(function (err) { 88 | assert.ok(err, 'Promise.catch is sent and error object'); 89 | assert.equal(err.status, 404, 'error.status is a 404'); 90 | assert.equal(err.message, 'not found'); 91 | done(); 92 | }); 93 | this.requests[0].respond(404, 94 | { 'Content-Type': 'text/json' }, 95 | '{"errors": [{"error_message": "not found"}]}'); 96 | }); 97 | 98 | it('should use the oauth_token if it is set', function (done) { 99 | SC.initialize({ 100 | client_id: 'YOUR_CLIENT_ID', 101 | redirect_uri: 'http://localhost:8080', 102 | oauth_token: 'SOME_OAUTH_TOKEN' 103 | }); 104 | 105 | SC.get('tracks/its-a-deep-bark').then(function (track) { 106 | done(); 107 | }.bind(this)).catch(function (err) { 108 | done(); 109 | }); 110 | 111 | assert.match(this.requests[0].url, /oauth_token=SOME_OAUTH_TOKEN/, 'ouath token exists in URL'); 112 | assert.notMatch(this.requests[0].url, /client_id=YOUR_CLIENT_ID/, 'client id does not exist in URL'); 113 | this.requests[0].respond(404, 114 | { 'Content-Type': 'text/json' }, 115 | '{"errors": [{"error_message": "not found"}]}'); 116 | }); 117 | 118 | }); 119 | 120 | it('should add the oauth_token to the query for non GET requests', function (done) { 121 | SC.initialize({ 122 | client_id: 'YOUR_CLIENT_ID', 123 | redirect_uri: 'http://localhost:8080', 124 | oauth_token: 'SOME_OAUTH_TOKEN' 125 | }); 126 | 127 | SC.put('tracks').then(function (track) { 128 | done(); 129 | }); 130 | 131 | assert.match(this.requests[0].url, /oauth_token=SOME_OAUTH_TOKEN/, 'ouath token exists in URL'); 132 | assert.notMatch(this.requests[0].url, /client_id=YOUR_CLIENT_ID/, 'client id does not exist in URL'); 133 | this.requests[0].respond(200, { 'Content-Type': 'text/json' }, '{}'); 134 | }); 135 | 136 | it('should make a POST when post is called', function (done) { 137 | SC.post('posting', {foo: 'bar'}).then(function (res) { 138 | assert.equal(this.requests[0].method, 'POST'); 139 | done(); 140 | }.bind(this)).catch(function (err) { 141 | done(err); 142 | }); 143 | this.requests[0].respond(200, 144 | { 'Content-Type': 'text/json' }, 145 | '{"kind": "track"}'); 146 | }); 147 | 148 | 149 | it('should make a PUT when put is called', function (done) { 150 | SC.put('putting', {foo: 'bar'}).then(function (res) { 151 | assert.equal(this.requests[0].method, 'PUT'); 152 | done(); 153 | }.bind(this)).catch(function (err) { 154 | done(err); 155 | }); 156 | this.requests[0].respond(200, 157 | { 'Content-Type': 'text/json' }, 158 | '{"kind": "track"}'); 159 | }); 160 | 161 | it('should make a DELETE when delete is called', function (done) { 162 | SC['delete']('deleting', {foo: 'bar'}).then(function (res) { 163 | assert.equal(this.requests[0].method, 'DELETE'); 164 | done(); 165 | }.bind(this)).catch(function (err) { 166 | done(err); 167 | }); 168 | this.requests[0].respond(200, 169 | { 'Content-Type': 'text/json' }, 170 | '{"status": "200 - OK"}'); 171 | }); 172 | 173 | describe('UPLOAD', function(){ 174 | if (!window.FormData) { 175 | console.log('Browser does not support FormData, will skip upload tests.'); 176 | return; 177 | } 178 | 179 | it('should send FormData for uploads', function(){ 180 | SC.initialize({ 181 | client_id: 'YOUR_CLIENT_ID', 182 | redirect_uri: 'http://localhost:8080', 183 | oauth_token: 'THE_TOKEN' 184 | }); 185 | 186 | SC.upload({asset_data: {}, title: {}}); 187 | assert.ok(this.requests[0].requestBody instanceof FormData); 188 | }); 189 | 190 | it('should allow `file` instead of asset_data', function(){ 191 | SC.initialize({ 192 | client_id: 'YOUR_CLIENT_ID', 193 | redirect_uri: 'http://localhost:8080', 194 | oauth_token: 'THE_TOKEN' 195 | }); 196 | 197 | SC.upload({file: {}, title: {}}); 198 | assert.ok(this.requests[0].requestBody instanceof FormData); 199 | }); 200 | 201 | it('should fail if not all needed params specified', function(done){ 202 | SC.upload({title: {}}).then(function(){ 203 | assert.fail(); 204 | done(); 205 | }).catch(function(){ 206 | assert.ok(true); 207 | done(); 208 | }); 209 | }); 210 | }); 211 | 212 | describe('resolve', function(){ 213 | it('should resolve URLs properly', function(){ 214 | var url = 'https://soundcloud.com/dj-perun/its-a-deep-bark'; 215 | SC.resolve(url); 216 | var requestUrl = decodeURIComponent(this.requests[0].url); 217 | var urlPart = requestUrl.split('url=')[1].split('&')[0]; 218 | assert.ok(urlPart === url, 'The URL in the request should match the original URL'); 219 | assert.ok(requestUrl.indexOf('&_status_code_map[302]=200') >= 0, 'Should be mapping a 302 to a 200'); 220 | }); 221 | }); 222 | }); 223 | -------------------------------------------------------------------------------- /test/initialize-test.js: -------------------------------------------------------------------------------- 1 | describe('SDK initialize', function () { 2 | it('should make isConnected true when oauth_token is set', function () { 3 | SC.initialize({ 4 | oauth_token: 'oauth_token' 5 | }); 6 | 7 | assert.ok(SC.isConnected()); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/recorder-test.js: -------------------------------------------------------------------------------- 1 | var Recorder = SC.Recorder; 2 | 3 | var AudioContext = window.AudioContext || window.webkitAudioContext; 4 | var context; 5 | if (AudioContext) { 6 | context = new AudioContext(); 7 | } 8 | 9 | var testRecorder = function(){ 10 | return new Recorder({ 11 | source: context.createBufferSource() 12 | }); 13 | }; 14 | 15 | // returns a silent oscillator 16 | var silentOscillator = function(){ 17 | var osc = context.createOscillator(); 18 | var gain = context.createGain(); 19 | gain.gain.value = 0; 20 | osc.connect(gain); 21 | gain.connect(context.destination); 22 | return osc; 23 | }; 24 | 25 | describe('Recorder', function () { 26 | if (!AudioContext) { 27 | console.log('Browser does not support the Web Audio API, will skip Recorder tests.'); 28 | return; 29 | } 30 | 31 | it('should initialize properly', function () { 32 | var options = { 33 | context: {}, 34 | source: {} 35 | }; 36 | 37 | var recorder = new Recorder(options); 38 | 39 | assert.equal(recorder.context, options.context, 'uses the provided context'); 40 | assert.equal(recorder.source, options.source, 'uses the provided source'); 41 | }); 42 | 43 | describe('start', function(){ 44 | it('should start the recording', function(done){ 45 | var recorder = testRecorder(); 46 | return recorder.start().then(function(){ 47 | assert.ok(recorder._recorder, 'instantiated the recorder'); 48 | done(); 49 | }).catch(function(err){ 50 | done(err); 51 | }); 52 | }); 53 | 54 | it('should throw an error if the source is not valid', function(done){ 55 | var recorder = new Recorder({ 56 | source: {} 57 | }); 58 | recorder.start().then(function(){ 59 | assert.fail('It should not go to `then`'); 60 | done() 61 | }).catch(function(err){ 62 | assert.ok(true); 63 | done(); 64 | }); 65 | }); 66 | }); 67 | 68 | describe('recording', function(){ 69 | it('should return a buffer', function(done){ 70 | var osc = silentOscillator(); 71 | var recorder = new Recorder({ 72 | context: context, 73 | source: osc 74 | }); 75 | osc.start(0); 76 | 77 | recorder.start().then(function(){ 78 | setTimeout(function(){ 79 | recorder.stop(); 80 | osc.stop(0); 81 | recorder.getBuffer().then(function(buffer){ 82 | assert.ok(buffer instanceof AudioBuffer, 'provides a valid buffer'); 83 | done(); 84 | }).catch(function(err){ 85 | done(err); 86 | }) 87 | }, 500); 88 | }); 89 | }); 90 | 91 | it('should return a wav', function(done){ 92 | var osc = silentOscillator(); 93 | var recorder = new Recorder({ 94 | context: context, 95 | source: osc 96 | }); 97 | osc.start(0); 98 | 99 | recorder.start().then(function(){ 100 | setTimeout(function(){ 101 | recorder.stop(); 102 | osc.stop(0); 103 | recorder.getWAV().then(function(wav){ 104 | assert.ok(wav instanceof Blob, 'provides a valid wav blob'); 105 | done(); 106 | }).catch(function(err){ 107 | done(err); 108 | }) 109 | }, 500); 110 | }); 111 | }); 112 | 113 | it('should fail if recording has not started', function(done){ 114 | var osc = silentOscillator(); 115 | var recorder = new Recorder({ 116 | context: context, 117 | source: osc 118 | }); 119 | osc.start(0); 120 | 121 | setTimeout(function(){ 122 | recorder.stop(); 123 | osc.stop(0); 124 | return recorder.getBuffer().catch(function(){ 125 | done(); 126 | }); 127 | }, 500); 128 | }); 129 | }); 130 | 131 | describe('play', function(){ 132 | it('should play the recording', function(done){ 133 | var osc = silentOscillator(); 134 | var recorder = new Recorder({ 135 | context: context, 136 | source: osc 137 | }); 138 | osc.start(0); 139 | 140 | recorder.start().then(function(){ 141 | setTimeout(function(){ 142 | recorder.stop(); 143 | osc.stop(0); 144 | recorder.play().then(function(node){ 145 | assert.ok(node instanceof AudioBufferSourceNode); 146 | done(); 147 | }).catch(function(err){ 148 | done(err); 149 | }); 150 | }, 500); 151 | }); 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /test/stream-test.js: -------------------------------------------------------------------------------- 1 | describe('SDK streaming', function () { 2 | beforeEach(function () { 3 | this.xhr = sinon.useFakeXMLHttpRequest(); 4 | 5 | this.requests = []; 6 | this.xhr.onCreate = function(xhr) { 7 | this.requests.push(xhr); 8 | }.bind(this); 9 | 10 | SC.initialize({ 11 | client_id: 'YOUR_CLIENT_ID', 12 | redirect_uri: 'http://localhost:8080' 13 | }); 14 | }); 15 | 16 | afterEach(function() { 17 | this.xhr.restore(); 18 | }); 19 | 20 | it('should only add a secret token to the url if given', function () { 21 | var secretToken = '123'; 22 | SC.stream('/tracks/cool-track'); 23 | assert(this.requests[0].url.indexOf('secret_token') === -1); 24 | 25 | SC.stream('/tracks/cool-track', secretToken); 26 | assert(this.requests[1].url.indexOf('secret_token') !== -1); 27 | assert(this.requests[1].url.indexOf(secretToken) !== -1); 28 | }); 29 | 30 | it('should create a player object', function(done){ 31 | SC.stream('/tracks/cool-track').then(function(player){ 32 | assert.ok(player); 33 | assert.ok(player.play); 34 | assert.ok(player.pause); 35 | done(); 36 | }).catch(function(e){ 37 | done(e); 38 | }); 39 | this.requests[0].respond(200, 40 | { 'Content-Type': 'text/json' }, 41 | '{"id": 123, "duration": 23}'); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /vendor/playback/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | SCAudio: require('@sc/scaudio'), 3 | SCAudioControllerHTML5Player: require('@sc/scaudio-controller-html5-player'), 4 | SCAudioControllerHLSMSEPlayer: require('@sc/scaudio-controller-hls-mse-player'), 5 | SCAudioPublicApiStreamURLRetriever: require('@sc/scaudio-public-api-stream-url-retriever'), 6 | MaestroCore: require('@sc/maestro-core'), 7 | MaestroLoaders: require('@sc/maestro-loaders') 8 | }; 9 | -------------------------------------------------------------------------------- /vendor/playback/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | 3 | module.exports = { 4 | entry: './index.js', 5 | output: { 6 | libraryTarget: 'commonjs2', 7 | filename: 'playback.js' 8 | }, 9 | plugins: [ new webpack.optimize.UglifyJsPlugin() ] 10 | }; 11 | -------------------------------------------------------------------------------- /vendor/recorderjs/recorder.js: -------------------------------------------------------------------------------- 1 | var RecorderWorker = require('./recorder.worker'); 2 | 3 | var Recorder = function(source, cfg){ 4 | var config = cfg || {}; 5 | var bufferLen = config.bufferLen || 4096; 6 | var numChannels = config.numChannels || 2; 7 | this.context = source.context; 8 | this.node = (this.context.createScriptProcessor || 9 | this.context.createJavaScriptNode).call(this.context, 10 | bufferLen, numChannels, numChannels); 11 | 12 | var worker = new RecorderWorker(); 13 | worker.postMessage({ 14 | command: 'init', 15 | config: { 16 | sampleRate: this.context.sampleRate, 17 | numChannels: numChannels 18 | } 19 | }); 20 | var recording = false, 21 | currCallback; 22 | 23 | this.node.onaudioprocess = function(e){ 24 | if (!recording) return; 25 | var buffer = []; 26 | for (var channel = 0; channel < numChannels; channel++){ 27 | buffer.push(e.inputBuffer.getChannelData(channel)); 28 | } 29 | worker.postMessage({ 30 | command: 'record', 31 | buffer: buffer 32 | }); 33 | } 34 | 35 | this.configure = function(cfg){ 36 | for (var prop in cfg){ 37 | if (cfg.hasOwnProperty(prop)){ 38 | config[prop] = cfg[prop]; 39 | } 40 | } 41 | } 42 | 43 | this.record = function(){ 44 | recording = true; 45 | } 46 | 47 | this.stop = function(){ 48 | recording = false; 49 | } 50 | 51 | this.clear = function(){ 52 | worker.postMessage({ command: 'clear' }); 53 | } 54 | 55 | this.getBuffer = function(cb) { 56 | currCallback = cb || config.callback; 57 | worker.postMessage({ command: 'getBuffer' }) 58 | } 59 | 60 | this.exportWAV = function(cb, type){ 61 | currCallback = cb || config.callback; 62 | type = type || config.type || 'audio/wav'; 63 | if (!currCallback) throw new Error('Callback not set'); 64 | worker.postMessage({ 65 | command: 'exportWAV', 66 | type: type 67 | }); 68 | } 69 | 70 | worker.onmessage = function(e){ 71 | var blob = e.data; 72 | currCallback(blob); 73 | } 74 | 75 | source.connect(this.node); 76 | this.node.connect(this.context.destination); //this should not be necessary 77 | }; 78 | 79 | Recorder.forceDownload = function(blob, filename){ 80 | var url = (window.URL || window.webkitURL).createObjectURL(blob); 81 | var link = window.document.createElement('a'); 82 | link.href = url; 83 | link.download = filename || 'output.wav'; 84 | var click = document.createEvent('Event'); 85 | click.initEvent('click', true, true); 86 | link.dispatchEvent(click); 87 | } 88 | 89 | module.exports = Recorder; 90 | -------------------------------------------------------------------------------- /vendor/recorderjs/recorder.worker.js: -------------------------------------------------------------------------------- 1 | var recLength = 0, 2 | recBuffers = [], 3 | sampleRate, 4 | numChannels; 5 | 6 | global.onmessage = function(e){ 7 | switch (e.data.command){ 8 | case 'init': 9 | init(e.data.config); 10 | break; 11 | case 'record': 12 | record(e.data.buffer); 13 | break; 14 | case 'exportWAV': 15 | exportWAV(e.data.type); 16 | break; 17 | case 'getBuffer': 18 | getBuffer(); 19 | break; 20 | case 'clear': 21 | clear(); 22 | break; 23 | } 24 | }; 25 | 26 | function init(config){ 27 | sampleRate = config.sampleRate; 28 | numChannels = config.numChannels; 29 | initBuffers(); 30 | } 31 | 32 | function record(inputBuffer){ 33 | for (var channel = 0; channel < numChannels; channel++){ 34 | recBuffers[channel].push(inputBuffer[channel]); 35 | } 36 | recLength += inputBuffer[0].length; 37 | } 38 | 39 | function exportWAV(type){ 40 | var buffers = []; 41 | for (var channel = 0; channel < numChannels; channel++){ 42 | buffers.push(mergeBuffers(recBuffers[channel], recLength)); 43 | } 44 | if (numChannels === 2){ 45 | var interleaved = interleave(buffers[0], buffers[1]); 46 | } else { 47 | var interleaved = buffers[0]; 48 | } 49 | var dataview = encodeWAV(interleaved); 50 | var audioBlob = new Blob([dataview], { type: type }); 51 | 52 | this.postMessage(audioBlob); 53 | } 54 | 55 | function getBuffer(){ 56 | var buffers = []; 57 | for (var channel = 0; channel < numChannels; channel++){ 58 | buffers.push(mergeBuffers(recBuffers[channel], recLength)); 59 | } 60 | this.postMessage(buffers); 61 | } 62 | 63 | function clear(){ 64 | recLength = 0; 65 | recBuffers = []; 66 | initBuffers(); 67 | } 68 | 69 | function initBuffers(){ 70 | for (var channel = 0; channel < numChannels; channel++){ 71 | recBuffers[channel] = []; 72 | } 73 | } 74 | 75 | function mergeBuffers(recBuffers, recLength){ 76 | var result = new Float32Array(recLength); 77 | var offset = 0; 78 | for (var i = 0; i < recBuffers.length; i++){ 79 | result.set(recBuffers[i], offset); 80 | offset += recBuffers[i].length; 81 | } 82 | return result; 83 | } 84 | 85 | function interleave(inputL, inputR){ 86 | var length = inputL.length + inputR.length; 87 | var result = new Float32Array(length); 88 | 89 | var index = 0, 90 | inputIndex = 0; 91 | 92 | while (index < length){ 93 | result[index++] = inputL[inputIndex]; 94 | result[index++] = inputR[inputIndex]; 95 | inputIndex++; 96 | } 97 | return result; 98 | } 99 | 100 | function floatTo16BitPCM(output, offset, input){ 101 | for (var i = 0; i < input.length; i++, offset+=2){ 102 | var s = Math.max(-1, Math.min(1, input[i])); 103 | output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true); 104 | } 105 | } 106 | 107 | function writeString(view, offset, string){ 108 | for (var i = 0; i < string.length; i++){ 109 | view.setUint8(offset + i, string.charCodeAt(i)); 110 | } 111 | } 112 | 113 | function encodeWAV(samples){ 114 | var buffer = new ArrayBuffer(44 + samples.length * 2); 115 | var view = new DataView(buffer); 116 | 117 | /* RIFF identifier */ 118 | writeString(view, 0, 'RIFF'); 119 | /* RIFF chunk length */ 120 | view.setUint32(4, 36 + samples.length * 2, true); 121 | /* RIFF type */ 122 | writeString(view, 8, 'WAVE'); 123 | /* format chunk identifier */ 124 | writeString(view, 12, 'fmt '); 125 | /* format chunk length */ 126 | view.setUint32(16, 16, true); 127 | /* sample format (raw) */ 128 | view.setUint16(20, 1, true); 129 | /* channel count */ 130 | view.setUint16(22, numChannels, true); 131 | /* sample rate */ 132 | view.setUint32(24, sampleRate, true); 133 | /* byte rate (sample rate * block align) */ 134 | view.setUint32(28, sampleRate * 4, true); 135 | /* block align (channel count * bytes per sample) */ 136 | view.setUint16(32, numChannels * 2, true); 137 | /* bits per sample */ 138 | view.setUint16(34, 16, true); 139 | /* data chunk identifier */ 140 | writeString(view, 36, 'data'); 141 | /* data chunk length */ 142 | view.setUint32(40, samples.length * 2, true); 143 | 144 | floatTo16BitPCM(view, 44, samples); 145 | 146 | return view; 147 | } 148 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const VERSION = require('./package.json').version; 2 | const IS_NPM = process.env.IS_NPM; 3 | const path = IS_NPM ? __dirname : __dirname + '/build/sdk'; 4 | const filename = IS_NPM ? 'sdk.js' : 'sdk-' + VERSION + '.js'; 5 | 6 | module.exports = { 7 | entry: './index.js', 8 | output: { 9 | path: path, 10 | filename: filename, 11 | libraryTarget: 'umd' 12 | }, 13 | module: { 14 | loaders: [{ 15 | test: /\.js?$/, 16 | exclude: /(node_modules|vendor)/, 17 | loader: 'babel-loader', 18 | query: { 19 | cacheDirectory: true 20 | } 21 | },{ 22 | test: /\.worker\.js?$/, 23 | exclude: /(node_modules)/, 24 | loader: 'worker-loader?inline=true', 25 | query: { 26 | inline: true 27 | } 28 | }] 29 | }, 30 | devServer: { 31 | contentBase: './examples' 32 | }, 33 | devtool: 'source-map' 34 | }; 35 | --------------------------------------------------------------------------------