hawk / commit
Added a drop-down for the target player.
public/index.html | 15 ++++-- public/js/api.js | 28 ++++++++++++ public/js/controls.js | 119 ++++++++++++++++++++++++++++++++++++++++++++++-- public/js/main.js | 5 +- public/styles/links.css | 21 +++++++++ public/styles/main.css | 29 ++++++++++-- 6 files changed, 204 insertions(+), 13 deletions(-) diff --git a/public/index.html b/public/index.html index 05d4562..d40e625 100644 --- a/public/index.html +++ b/public/index.html @@ -7,6 +7,7 @@ <link rel="icon" type="image/png" href="images/favicon.png"> <link rel="stylesheet" href="styles/collapsible.css"> <link rel="stylesheet" href="styles/song.css"> + <link rel="stylesheet" href="styles/links.css"> <link rel="stylesheet" href="styles/main.css"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" /> @@ -15,8 +16,8 @@ <body> <!-- HEADER --> <div> - <div id="header"> - <span class="right"> + <div id="header" class="flex"> + <span class="left"> <h1 id="logo-and-text"> <a href="https://pavelsafronov.gitlab.io/hawk/index.html"> <img id="logo" src="images/hawk.png" /> @@ -24,7 +25,7 @@ <span>Hawk</span> </h1> </span> - <span class="left"> + <span class="right"> <a href="https://spotify.com/" target="_blank"> <img id="spotify-logo" src="images/Spotify_Logo_RGB_Black.png" /> </a> @@ -81,6 +82,10 @@ </button> </div> <div class="signedin"> + <div class="flex"> + <h3 class="left">Target player:</h3> + <select id="devices" class="right" required></select> + </div> <div> <h3>Search for a song by duration:</h3> </div> @@ -96,8 +101,8 @@ <button id="search" onclick="searchForMatch()">Search</button> </div> - <div> - <span id="status"></span> + <div class="flex"> + <div id="status"></div> </div> <div> diff --git a/public/js/api.js b/public/js/api.js index 0bff57e..7970bd7 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -182,3 +182,31 @@ async function pause() { } }); } + +async function getAvailableDevices() { + const accessToken = await getAccessToken(); + const response = await fetch("https://api.spotify.com/v1/me/player/devices", { + headers: { + Authorization: 'Bearer ' + accessToken + } + }); + const data = await response.json(); + return data.devices; +} + +async function transferPlayback(deviceId) { + const accessToken = await getAccessToken(); + const response = await fetch("https://api.spotify.com/v1/me/player", { + method: "PUT", + headers: { + Authorization: 'Bearer ' + accessToken + }, + body: JSON.stringify({ + device_ids: [deviceId] + }) + }); + if (response.status != 204) { + const json = await response.json(); + throw new Error(`Failed to transfer playback to ${deviceId}: ${response.status}\n${JSON.stringify(json)}`); + } +} \ No newline at end of file diff --git a/public/js/controls.js b/public/js/controls.js index 96acb07..3d14665 100644 --- a/public/js/controls.js +++ b/public/js/controls.js @@ -1,11 +1,20 @@ -let statusText = ''; +let pageStatus = { + text: '', + isError: false +}; setInterval(() => { const statusDiv = document.getElementById('status'); - statusDiv.innerText = statusText; + statusDiv.innerText = pageStatus.text; + if (pageStatus.isError) { + statusDiv.classList.add('error'); + } else { + statusDiv.classList.remove('error'); + } }, 100); -function reportStatus(text) { - statusText = text; +function reportStatus(text, isError = false) { + pageStatus.text = text; + pageStatus.isError = isError; } async function pauseOrPlay() { @@ -19,6 +28,17 @@ async function pauseOrPlay() { } async function playChosenSong(trackName, trackId) { + // clear status + reportStatus('') + + // get select element + const devicesList = document.getElementById('devices'); + const deviceId = devicesList.value; + if (!deviceId) { + reportStatus('Cannot play track, no device selected', true); + return; + } + try { await playTrack(trackId); } catch (e) { @@ -165,3 +185,94 @@ async function setCurrentPlayingSong() { } currentSongSpan.innerHTML = html; } + +async function configureDevicesList() { + // get select element + const devicesList = document.getElementById('devices'); + + await updateDevicesList(devicesList); + setInterval(async () => { + await updateDevicesList(devicesList); + }, 1000 * 5); + devicesList.addEventListener('change', async () => { + const deviceId = devicesList.value; + saveDeviceId(deviceId); + if (deviceId) { + console.info(`Setting device to ${deviceId}`); + await transferPlayback(deviceId); + } + }); +} + +async function updateDevicesList(devicesList) { + // get available devices + const devices = await getAvailableDevices(); + + // add all devices to the select as options + let activeDeviceId = ""; + for (let i = 0; i < devices.length; i++) { + const device = devices[i]; + const ignoreDevice = device.is_restricted || device.is_private_session; + + // if the device is active, save its id + if (device.is_active) { + activeDeviceId = device.id; + } + + // check if the device is already in the list + const existingOption = devicesList.querySelector(`option[value="${device.id}"]`); + if (existingOption) { + if (ignoreDevice) { + // remove the device from the list + devicesList.removeChild(existingOption); + } else { + // update the name of the device, if it has changed + if (existingOption.text != device.name) { + existingOption.text = device.name; + } + // enable the option, if it was disabled + if (existingOption.hasAttribute('disabled')) { + existingOption.removeAttribute('disabled'); + } + } + } else if (!ignoreDevice) { + // add the device to the list + const option = document.createElement('option'); + option.value = device.id; + option.text = device.name; + devicesList.appendChild(option); + } + } + + // disable options that are no longer available + const options = devicesList.querySelectorAll('option'); + for (let i = 0; i < options.length; i++) { + const option = options[i]; + const existingDevice = devices.find(device => device.id == option.value); + if (!existingDevice) { + option.setAttribute('disabled', 'disabled'); + if (option.value == devicesList.value) { + devicesList.value = ""; + devicesList.dispatchEvent(new Event('change')); + } + } + } + + // if there is an active device, select it and save the id + if (activeDeviceId) { + devicesList.value = activeDeviceId; + saveDeviceId(activeDeviceId); + } else { + const savedDeviceId = getSavedDeviceId(); + if (savedDeviceId) { + devicesList.value = savedDeviceId; + } + } +} + +function saveDeviceId(deviceId) { + localStorage.setItem('active_device_id', deviceId); +} +function getSavedDeviceId() { + return localStorage.getItem('active_device_id'); +} diff --git a/public/js/main.js b/public/js/main.js index 064102b..4dcfb13 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -38,4 +38,7 @@ if (userIsSignedIn) { hideOrShow('signedout', false); setCurrentPlayingSong(); setInterval(setCurrentPlayingSong, 5 * 1000); -} \ No newline at end of file +} + +// periodically lookup the active device +configureDevicesList(); \ No newline at end of file diff --git a/public/styles/links.css b/public/styles/links.css new file mode 100644 index 0000000..3f3ab8c --- /dev/null +++ b/public/styles/links.css @@ -0,0 +1,21 @@ +a:link { + color: #333; + /* dark gray */ + text-decoration: none; +} + +a:visited { + color: #666; + /* medium gray */ + text-decoration: none; +} + +a:hover { + color: #1DB954; + text-decoration: underline; +} + +a:active { + color: #000; + text-decoration: underline; +} \ No newline at end of file diff --git a/public/styles/main.css b/public/styles/main.css index 687aa04..81ce4fd 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -24,18 +24,18 @@ button { align-items: center; } -#header { +.flex { text-align: center; display: flex; align-items: center; } .left { - margin-left: auto; + margin-right: auto; } .right { - margin-right: auto; + margin-left: auto; } #logo { @@ -100,4 +100,27 @@ button { width: 1em; height: 1em; margin-right: 1em; +} + +#devices { + margin-left: 2rem; + border: 2px solid black; + font-size: 1em; +} + +#devices:invalid { + /* Styles for when no option is selected */ + border-color: red; +} + +#status { + /* margin: 1em; */ + /* padding: 1em; */ + border: 0 solid black; + font-size: 1em; + width: 100%; +} + +#status.error { + background-color: pink; } \ No newline at end of file