Skip to content

Commit

Permalink
Initial Commit
Browse files Browse the repository at this point in the history
  • Loading branch information
tomjaimz committed Nov 3, 2024
0 parents commit e5e3322
Show file tree
Hide file tree
Showing 5 changed files with 521 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.settings.json
.DS_Store
.settings.json.old
20 changes: 20 additions & 0 deletions README.md
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
225 changes: 225 additions & 0 deletions client.js
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();
}
};
41 changes: 41 additions & 0 deletions index.html
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>
Loading

0 comments on commit e5e3322

Please sign in to comment.