-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit e5e3322
Showing
5 changed files
with
521 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
.settings.json | ||
.DS_Store | ||
.settings.json.old |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
# nthm | ||
|
||
minimal client/server implementation of the public Web Playback SDK | ||
|
||
run with `node server.js` (install not required) | ||
|
||
recommended order of operations is | ||
|
||
1. connect (can be set to connect automatically) | ||
2. transfer (should be automatic if connect was successful) | ||
3. (if paused) togglePlay | ||
|
||
- requires nodejs, but uses no npm modules (except a json formatter loaded from a cdn client-side) | ||
- prompts for client id, client secret, and redirect uri | ||
- stores client settings in a local settings json file | ||
- runs and opens a web server | ||
- token handling including renewal - stored in the client's local storage | ||
- transfers playback to local device | ||
- provides buttons for main methods | ||
- anthem events logged to console and in browser |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,225 @@ | ||
|
||
const saveSettings = async (key, id) => { | ||
console.log({ key, id }) | ||
const settingsResponse = await fetch('/settings') | ||
const settings = await settingsResponse.json() | ||
settings.client[key] = document.getElementById(id).value | ||
await writeSettings(settings) | ||
} | ||
|
||
const writeSettings = async (settings) => await fetch('/settings', { | ||
method: 'PUT', | ||
body: JSON.stringify(settings), | ||
headers: { 'Content-Type': 'application/json' }, | ||
}) | ||
|
||
const readSettings = async () => { | ||
const settingsResponse = await fetch('/settings') | ||
const settings = await settingsResponse.json() | ||
const { id, secret, redirect_uri } = settings?.client | ||
const token = JSON.parse(localStorage.getItem('token')) | ||
document.getElementById("clientId").value = id | ||
document.getElementById("clientSecret").value = secret | ||
document.getElementById("redirectURI").value = redirect_uri | ||
document.getElementById("accessToken").value = token.access_token | ||
document.getElementById("refreshToken").value = token.refresh_token | ||
|
||
document.getElementById("clientId").onchange = async () => saveSettings("id", "clientId") | ||
document.getElementById("clientSecret").onchange = async () => saveSettings("secret", "clientSecret") | ||
document.getElementById("redirectURI").onchange = async () => saveSettings("redirect_uri", "redirectURI") | ||
|
||
document.getElementById("saveClientId").onclick = async () => saveSettings("id", "clientId") | ||
document.getElementById("saveClientSecret").onclick = async () => saveSettings("secret", "clientSecret") | ||
document.getElementById("saveRedirectURI").onclick = async () => saveSettings("redirect_uri", "redirectURI") | ||
|
||
|
||
document.getElementById("accessToken").onchange = async () => { | ||
token.access_token = document.getElementById("accessToken").value | ||
localStorage.setItem('token', JSON.stringify(token)); | ||
} | ||
document.getElementById("refreshToken").onchange = async () => { | ||
token.access_token = document.getElementById("refreshToken").value | ||
localStorage.setItem('token', JSON.stringify(token)); | ||
} | ||
} | ||
|
||
// client function | ||
window.onSpotifyWebPlaybackSDKReady = async () => { | ||
/* Add Button Actions */ | ||
const autoConnectCheckbox = document.getElementById("autoConnectCheckbox"); | ||
autoConnectCheckbox.checked = JSON.parse(localStorage.getItem("autoConnectCheckbox")); | ||
autoConnectCheckbox.onclick = e => { | ||
localStorage.setItem("autoConnectCheckbox", JSON.stringify(e.target.checked)); | ||
}; | ||
|
||
const playButton = document.getElementById('playButton') | ||
playButton.onclick = async () => { | ||
const inputValue = document.getElementById("urlInput").value; | ||
const match = inputValue.match(/https:\/\/open\.spotify\.com\/(.*)\/([^?]*)/); | ||
const [, type, id] = match ? match : inputValue.split(':'); | ||
const uri = `spotify:${type}:${id}`; | ||
await api(`me/player/play`, 'PUT', ['track', 'episode'].includes(type) ? { uris: [uri] } : { context_uri: uri }); | ||
}; | ||
|
||
const actionElement = document.getElementById("actions") | ||
for (const action of ACTIONS) { | ||
const button = document.createElement('button'); | ||
const [func, parameter] = action.split('#'); | ||
button.onclick = async () => log({ | ||
[action]: await player[func](parameter ? parameter : null) | ||
}); | ||
button.innerText = func + (parameter ? `(${parameter})` : ''); | ||
actionElement.append(button, ' '); | ||
} | ||
|
||
const logDiv = document.getElementById("log") | ||
logDiv.addEventListener("click", () => { | ||
if (logDiv.scrollTop === logDiv.scrollHeight) | ||
setTimeout(() => { logDiv.scrollTop = logDiv.scrollHeight; }, 400); | ||
}); | ||
|
||
const log = v => { | ||
const formatter = new JSONFormatter(v, 1, { hoverPreviewEnabled: true, hoverPreviewArrayCount: 100, hoverPreviewFieldCount: 5, }); | ||
logDiv.appendChild(formatter.render()); | ||
logDiv.scrollTop = logDiv.scrollHeight; | ||
console.log((new Date()).toISOString(), v); | ||
}; | ||
|
||
/* Token Handling */ | ||
|
||
const { origin, pathname, searchParams } = new URL(document.location); | ||
|
||
const requestToken = async (body) => { | ||
const response = await fetch(`/requestToken`, { | ||
method: 'POST', | ||
body: JSON.stringify(body), | ||
headers: { 'Content-Type': 'application/json' }, | ||
}); | ||
if (response.status >= '400') { | ||
return await response.json(); | ||
} | ||
token = await response.json(); | ||
if (!token.refresh_token && body.refresh_token) token.refresh_token = body.refresh_token; | ||
token.expires_at = new Date().setSeconds(new Date().getSeconds() + token.expires_in); | ||
localStorage.setItem('token', JSON.stringify(token)); | ||
return token.access_token; | ||
}; | ||
|
||
if (searchParams.get('code')) { // have been redirected from spotify callback | ||
const redirect_uri = `${origin}${pathname}`; | ||
const response = await requestToken({ code: searchParams.get('code'), state: searchParams.get('state'), grant_type: 'authorization_code', redirect_uri }); | ||
const redirect_uri_invalid = (response.statusCode == 400 && response.body?.error_description == 'Invalid redirect URI') | ||
location.href = `${pathname}?request_result=${(redirect_uri_invalid ? 'invalid_redirect_uri' : 'success')}` | ||
return | ||
} | ||
|
||
const newToken = async () => { | ||
location.href = '/authorizeUrl'; | ||
} | ||
|
||
const refreshToken = async () => { | ||
const { refresh_token } = JSON.parse(localStorage.getItem('token')) | ||
const response = await requestToken({ refresh_token, grant_type: 'refresh_token' }) | ||
if (response.statusCode == 400) { | ||
if (response.body.error == 'invalid_grant') { | ||
token = {} | ||
localStorage.setItem('token', JSON.stringify(token)); | ||
return "Token has been revoked. Use newToken to create a new token." | ||
} | ||
if (response.body.error == 'invalid_request') { | ||
return "No token. Use newToken to create a new token." | ||
} | ||
} | ||
return response | ||
} | ||
|
||
const getToken = async () => { | ||
const token = JSON.parse(localStorage.getItem('token')) || {}; | ||
const { access_token, expires_at } = token; | ||
if (!access_token) { | ||
return newToken(); | ||
} | ||
if (new Date(expires_at) < new Date()) { | ||
return await refreshToken() | ||
} | ||
return access_token; | ||
}; | ||
|
||
const api = async (url, method, body) => fetch(`https://api.spotify.com/v1/${url}`, { | ||
method, | ||
body: JSON.stringify(body), | ||
headers: { Authorization: `Bearer ${await getToken()}`, 'Content-Type': 'application/json' }, | ||
}); | ||
|
||
if (searchParams.get('request_result') == 'invalid_redirect_uri') { // have been redirected from spotify callback | ||
log(`ERROR: Redirect URI ${origin}${pathname} has not been registered.`) | ||
} | ||
|
||
if (searchParams.get('request_result') == 'success') { // have been redirected from spotify callback | ||
log(`New token created.`) | ||
} | ||
|
||
log("DOM Initialised"); | ||
|
||
await readSettings() | ||
|
||
/* Initialise Player */ | ||
const player = new Spotify.Player({ | ||
name: PLAYER_NAME, | ||
getOAuthToken: async fn => fn(await getToken()) | ||
}); | ||
|
||
log(`Player created with name ${PLAYER_NAME}`); | ||
|
||
player.newToken = newToken | ||
|
||
player.refreshToken = refreshToken | ||
|
||
player.transfer = async () => { | ||
const { device_id } = player._options; | ||
if (device_id) { | ||
const response = await api('me/player', 'PUT', { device_ids: [device_id] }); | ||
if (response.status === 404) { | ||
const { message } = await response.json(); | ||
if (message === "Device not found") { | ||
log('Device not found: Try connect first'); | ||
} | ||
} | ||
} else { | ||
log('No Device ID: Try connect first'); | ||
} | ||
return device_id; | ||
}; | ||
|
||
player.me = async () => await (await api('me', 'GET')).json(); | ||
|
||
player.playerState = async () => { | ||
const playerResponse = await api('me/player', 'GET') | ||
if (playerResponse.status == 204) { | ||
return "Nothing playing right now" | ||
} | ||
return await playerResponse.json() | ||
}; | ||
|
||
for (const event of EVENTS) { | ||
player.addListener(`${event}`, body => { | ||
if (event === 'ready') { | ||
log({ body }) | ||
player._options.device_id = body.device_id; | ||
log("Auto transferring..."); | ||
player.transfer(); | ||
} | ||
if (event === 'playback_error' && body.message === 'Cannot perform operation; no list was loaded.') { | ||
log('Try transfer to transfer playback to this client first.'); | ||
} | ||
log({ | ||
[event]: body | ||
}); | ||
}); | ||
} | ||
|
||
if (JSON.parse(localStorage.getItem("autoConnectCheckbox"))) { | ||
log("Auto connecting..."); | ||
player.connect(); | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
<style> | ||
body { | ||
font: 13.333px sans-serif; | ||
} | ||
|
||
#autoConnectCheckbox { | ||
vertical-align: middle; | ||
position: relative; | ||
bottom: 0.08em | ||
} | ||
|
||
#log { | ||
height: 400px; | ||
overflow: auto; | ||
margin: 10px; | ||
} | ||
|
||
label { | ||
display: inline-block; | ||
text-align: right; | ||
width: 200px; | ||
padding-right: 10px; | ||
margin: 0px; | ||
} | ||
</style> | ||
<div> | ||
<label for="autoConnectCheckbox"></label><input id="autoConnectCheckbox" type="checkbox" /> Automatically | ||
Connect<br /> | ||
<label for="clientId">Client ID</label><input id="clientId" placeholder="Enter Client ID"> <button | ||
id="saveClientId">Save</button><br /> | ||
<label for="clientSecret">Client Secret</label><input type="password" id="clientSecret" | ||
placeholder="Enter Client Secret"> <button id="saveClientSecret">Save</button><br /> | ||
<label for="redirectURI">Redirect URI</label><input id="redirectURI" placeholder="Enter Redirect URI"> <button | ||
id="saveRedirectURI">Save</button><br /> | ||
<label for="accessToken">Access Token</label><input id="accessToken" placeholder="Enter Access Token"><br /> | ||
<label for="refreshToken">Refresh Token</label><input id="refreshToken" placeholder="Enter Refresh Token"><br /> | ||
<label for="urlInput">URI/URL to Play</label><input id="urlInput" placeholder="Enter URL or URI"> <button | ||
id="playButton">Play</button> | ||
</div> | ||
<p id="actions"></p> | ||
<div id="log"></div> |
Oops, something went wrong.